openhands-sdk 1.7.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 (172) hide show
  1. openhands/sdk/__init__.py +111 -0
  2. openhands/sdk/agent/__init__.py +8 -0
  3. openhands/sdk/agent/agent.py +607 -0
  4. openhands/sdk/agent/base.py +454 -0
  5. openhands/sdk/agent/prompts/in_context_learning_example.j2 +169 -0
  6. openhands/sdk/agent/prompts/in_context_learning_example_suffix.j2 +3 -0
  7. openhands/sdk/agent/prompts/model_specific/anthropic_claude.j2 +3 -0
  8. openhands/sdk/agent/prompts/model_specific/google_gemini.j2 +1 -0
  9. openhands/sdk/agent/prompts/model_specific/openai_gpt/gpt-5-codex.j2 +3 -0
  10. openhands/sdk/agent/prompts/model_specific/openai_gpt/gpt-5.j2 +3 -0
  11. openhands/sdk/agent/prompts/security_policy.j2 +22 -0
  12. openhands/sdk/agent/prompts/security_risk_assessment.j2 +21 -0
  13. openhands/sdk/agent/prompts/self_documentation.j2 +15 -0
  14. openhands/sdk/agent/prompts/system_prompt.j2 +132 -0
  15. openhands/sdk/agent/prompts/system_prompt_interactive.j2 +14 -0
  16. openhands/sdk/agent/prompts/system_prompt_long_horizon.j2 +40 -0
  17. openhands/sdk/agent/prompts/system_prompt_planning.j2 +40 -0
  18. openhands/sdk/agent/prompts/system_prompt_tech_philosophy.j2 +122 -0
  19. openhands/sdk/agent/utils.py +223 -0
  20. openhands/sdk/context/__init__.py +28 -0
  21. openhands/sdk/context/agent_context.py +240 -0
  22. openhands/sdk/context/condenser/__init__.py +18 -0
  23. openhands/sdk/context/condenser/base.py +95 -0
  24. openhands/sdk/context/condenser/llm_summarizing_condenser.py +89 -0
  25. openhands/sdk/context/condenser/no_op_condenser.py +13 -0
  26. openhands/sdk/context/condenser/pipeline_condenser.py +55 -0
  27. openhands/sdk/context/condenser/prompts/summarizing_prompt.j2 +59 -0
  28. openhands/sdk/context/prompts/__init__.py +6 -0
  29. openhands/sdk/context/prompts/prompt.py +114 -0
  30. openhands/sdk/context/prompts/templates/ask_agent_template.j2 +11 -0
  31. openhands/sdk/context/prompts/templates/skill_knowledge_info.j2 +8 -0
  32. openhands/sdk/context/prompts/templates/system_message_suffix.j2 +32 -0
  33. openhands/sdk/context/skills/__init__.py +28 -0
  34. openhands/sdk/context/skills/exceptions.py +11 -0
  35. openhands/sdk/context/skills/skill.py +630 -0
  36. openhands/sdk/context/skills/trigger.py +36 -0
  37. openhands/sdk/context/skills/types.py +48 -0
  38. openhands/sdk/context/view.py +306 -0
  39. openhands/sdk/conversation/__init__.py +40 -0
  40. openhands/sdk/conversation/base.py +281 -0
  41. openhands/sdk/conversation/conversation.py +146 -0
  42. openhands/sdk/conversation/conversation_stats.py +85 -0
  43. openhands/sdk/conversation/event_store.py +157 -0
  44. openhands/sdk/conversation/events_list_base.py +17 -0
  45. openhands/sdk/conversation/exceptions.py +50 -0
  46. openhands/sdk/conversation/fifo_lock.py +133 -0
  47. openhands/sdk/conversation/impl/__init__.py +5 -0
  48. openhands/sdk/conversation/impl/local_conversation.py +620 -0
  49. openhands/sdk/conversation/impl/remote_conversation.py +883 -0
  50. openhands/sdk/conversation/persistence_const.py +9 -0
  51. openhands/sdk/conversation/response_utils.py +41 -0
  52. openhands/sdk/conversation/secret_registry.py +126 -0
  53. openhands/sdk/conversation/serialization_diff.py +0 -0
  54. openhands/sdk/conversation/state.py +352 -0
  55. openhands/sdk/conversation/stuck_detector.py +311 -0
  56. openhands/sdk/conversation/title_utils.py +191 -0
  57. openhands/sdk/conversation/types.py +45 -0
  58. openhands/sdk/conversation/visualizer/__init__.py +12 -0
  59. openhands/sdk/conversation/visualizer/base.py +67 -0
  60. openhands/sdk/conversation/visualizer/default.py +373 -0
  61. openhands/sdk/critic/__init__.py +15 -0
  62. openhands/sdk/critic/base.py +38 -0
  63. openhands/sdk/critic/impl/__init__.py +12 -0
  64. openhands/sdk/critic/impl/agent_finished.py +83 -0
  65. openhands/sdk/critic/impl/empty_patch.py +49 -0
  66. openhands/sdk/critic/impl/pass_critic.py +42 -0
  67. openhands/sdk/event/__init__.py +42 -0
  68. openhands/sdk/event/base.py +149 -0
  69. openhands/sdk/event/condenser.py +82 -0
  70. openhands/sdk/event/conversation_error.py +25 -0
  71. openhands/sdk/event/conversation_state.py +104 -0
  72. openhands/sdk/event/llm_completion_log.py +39 -0
  73. openhands/sdk/event/llm_convertible/__init__.py +20 -0
  74. openhands/sdk/event/llm_convertible/action.py +139 -0
  75. openhands/sdk/event/llm_convertible/message.py +142 -0
  76. openhands/sdk/event/llm_convertible/observation.py +141 -0
  77. openhands/sdk/event/llm_convertible/system.py +61 -0
  78. openhands/sdk/event/token.py +16 -0
  79. openhands/sdk/event/types.py +11 -0
  80. openhands/sdk/event/user_action.py +21 -0
  81. openhands/sdk/git/exceptions.py +43 -0
  82. openhands/sdk/git/git_changes.py +249 -0
  83. openhands/sdk/git/git_diff.py +129 -0
  84. openhands/sdk/git/models.py +21 -0
  85. openhands/sdk/git/utils.py +189 -0
  86. openhands/sdk/io/__init__.py +6 -0
  87. openhands/sdk/io/base.py +48 -0
  88. openhands/sdk/io/local.py +82 -0
  89. openhands/sdk/io/memory.py +54 -0
  90. openhands/sdk/llm/__init__.py +45 -0
  91. openhands/sdk/llm/exceptions/__init__.py +45 -0
  92. openhands/sdk/llm/exceptions/classifier.py +50 -0
  93. openhands/sdk/llm/exceptions/mapping.py +54 -0
  94. openhands/sdk/llm/exceptions/types.py +101 -0
  95. openhands/sdk/llm/llm.py +1140 -0
  96. openhands/sdk/llm/llm_registry.py +122 -0
  97. openhands/sdk/llm/llm_response.py +59 -0
  98. openhands/sdk/llm/message.py +656 -0
  99. openhands/sdk/llm/mixins/fn_call_converter.py +1243 -0
  100. openhands/sdk/llm/mixins/non_native_fc.py +93 -0
  101. openhands/sdk/llm/options/__init__.py +1 -0
  102. openhands/sdk/llm/options/chat_options.py +93 -0
  103. openhands/sdk/llm/options/common.py +19 -0
  104. openhands/sdk/llm/options/responses_options.py +67 -0
  105. openhands/sdk/llm/router/__init__.py +10 -0
  106. openhands/sdk/llm/router/base.py +117 -0
  107. openhands/sdk/llm/router/impl/multimodal.py +76 -0
  108. openhands/sdk/llm/router/impl/random.py +22 -0
  109. openhands/sdk/llm/streaming.py +9 -0
  110. openhands/sdk/llm/utils/metrics.py +312 -0
  111. openhands/sdk/llm/utils/model_features.py +191 -0
  112. openhands/sdk/llm/utils/model_info.py +90 -0
  113. openhands/sdk/llm/utils/model_prompt_spec.py +98 -0
  114. openhands/sdk/llm/utils/retry_mixin.py +128 -0
  115. openhands/sdk/llm/utils/telemetry.py +362 -0
  116. openhands/sdk/llm/utils/unverified_models.py +156 -0
  117. openhands/sdk/llm/utils/verified_models.py +66 -0
  118. openhands/sdk/logger/__init__.py +22 -0
  119. openhands/sdk/logger/logger.py +195 -0
  120. openhands/sdk/logger/rolling.py +113 -0
  121. openhands/sdk/mcp/__init__.py +24 -0
  122. openhands/sdk/mcp/client.py +76 -0
  123. openhands/sdk/mcp/definition.py +106 -0
  124. openhands/sdk/mcp/exceptions.py +19 -0
  125. openhands/sdk/mcp/tool.py +270 -0
  126. openhands/sdk/mcp/utils.py +83 -0
  127. openhands/sdk/observability/__init__.py +4 -0
  128. openhands/sdk/observability/laminar.py +166 -0
  129. openhands/sdk/observability/utils.py +20 -0
  130. openhands/sdk/py.typed +0 -0
  131. openhands/sdk/secret/__init__.py +19 -0
  132. openhands/sdk/secret/secrets.py +92 -0
  133. openhands/sdk/security/__init__.py +6 -0
  134. openhands/sdk/security/analyzer.py +111 -0
  135. openhands/sdk/security/confirmation_policy.py +61 -0
  136. openhands/sdk/security/llm_analyzer.py +29 -0
  137. openhands/sdk/security/risk.py +100 -0
  138. openhands/sdk/tool/__init__.py +34 -0
  139. openhands/sdk/tool/builtins/__init__.py +34 -0
  140. openhands/sdk/tool/builtins/finish.py +106 -0
  141. openhands/sdk/tool/builtins/think.py +117 -0
  142. openhands/sdk/tool/registry.py +161 -0
  143. openhands/sdk/tool/schema.py +276 -0
  144. openhands/sdk/tool/spec.py +39 -0
  145. openhands/sdk/tool/tool.py +481 -0
  146. openhands/sdk/utils/__init__.py +22 -0
  147. openhands/sdk/utils/async_executor.py +115 -0
  148. openhands/sdk/utils/async_utils.py +39 -0
  149. openhands/sdk/utils/cipher.py +68 -0
  150. openhands/sdk/utils/command.py +90 -0
  151. openhands/sdk/utils/deprecation.py +166 -0
  152. openhands/sdk/utils/github.py +44 -0
  153. openhands/sdk/utils/json.py +48 -0
  154. openhands/sdk/utils/models.py +570 -0
  155. openhands/sdk/utils/paging.py +63 -0
  156. openhands/sdk/utils/pydantic_diff.py +85 -0
  157. openhands/sdk/utils/pydantic_secrets.py +64 -0
  158. openhands/sdk/utils/truncate.py +117 -0
  159. openhands/sdk/utils/visualize.py +58 -0
  160. openhands/sdk/workspace/__init__.py +17 -0
  161. openhands/sdk/workspace/base.py +158 -0
  162. openhands/sdk/workspace/local.py +189 -0
  163. openhands/sdk/workspace/models.py +35 -0
  164. openhands/sdk/workspace/remote/__init__.py +8 -0
  165. openhands/sdk/workspace/remote/async_remote_workspace.py +149 -0
  166. openhands/sdk/workspace/remote/base.py +164 -0
  167. openhands/sdk/workspace/remote/remote_workspace_mixin.py +323 -0
  168. openhands/sdk/workspace/workspace.py +49 -0
  169. openhands_sdk-1.7.0.dist-info/METADATA +17 -0
  170. openhands_sdk-1.7.0.dist-info/RECORD +172 -0
  171. openhands_sdk-1.7.0.dist-info/WHEEL +5 -0
  172. openhands_sdk-1.7.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,146 @@
1
+ from collections.abc import Mapping
2
+ from pathlib import Path
3
+ from typing import TYPE_CHECKING, Self, overload
4
+
5
+ from openhands.sdk.agent.base import AgentBase
6
+ from openhands.sdk.conversation.base import BaseConversation
7
+ from openhands.sdk.conversation.types import (
8
+ ConversationCallbackType,
9
+ ConversationID,
10
+ ConversationTokenCallbackType,
11
+ StuckDetectionThresholds,
12
+ )
13
+ from openhands.sdk.conversation.visualizer import (
14
+ ConversationVisualizerBase,
15
+ DefaultConversationVisualizer,
16
+ )
17
+ from openhands.sdk.logger import get_logger
18
+ from openhands.sdk.secret import SecretValue
19
+ from openhands.sdk.workspace import LocalWorkspace, RemoteWorkspace
20
+
21
+
22
+ if TYPE_CHECKING:
23
+ from openhands.sdk.conversation.impl.local_conversation import LocalConversation
24
+ from openhands.sdk.conversation.impl.remote_conversation import RemoteConversation
25
+
26
+ logger = get_logger(__name__)
27
+
28
+
29
+ class Conversation:
30
+ """Factory class for creating conversation instances with OpenHands agents.
31
+
32
+ This factory automatically creates either a LocalConversation or RemoteConversation
33
+ based on the workspace type provided. LocalConversation runs the agent locally,
34
+ while RemoteConversation connects to a remote agent server.
35
+
36
+ Returns:
37
+ LocalConversation if workspace is local, RemoteConversation if workspace
38
+ is remote.
39
+
40
+ Example:
41
+ >>> from openhands.sdk import LLM, Agent, Conversation
42
+ >>> llm = LLM(model="claude-sonnet-4-20250514", api_key=SecretStr("key"))
43
+ >>> agent = Agent(llm=llm, tools=[])
44
+ >>> conversation = Conversation(agent=agent, workspace="./workspace")
45
+ >>> conversation.send_message("Hello!")
46
+ >>> conversation.run()
47
+ """
48
+
49
+ @overload
50
+ def __new__(
51
+ cls: type[Self],
52
+ agent: AgentBase,
53
+ *,
54
+ workspace: str | Path | LocalWorkspace = "workspace/project",
55
+ persistence_dir: str | Path | None = None,
56
+ conversation_id: ConversationID | None = None,
57
+ callbacks: list[ConversationCallbackType] | None = None,
58
+ token_callbacks: list[ConversationTokenCallbackType] | None = None,
59
+ max_iteration_per_run: int = 500,
60
+ stuck_detection: bool = True,
61
+ stuck_detection_thresholds: (
62
+ StuckDetectionThresholds | Mapping[str, int] | None
63
+ ) = None,
64
+ visualizer: (
65
+ type[ConversationVisualizerBase] | ConversationVisualizerBase | None
66
+ ) = DefaultConversationVisualizer,
67
+ secrets: dict[str, SecretValue] | dict[str, str] | None = None,
68
+ ) -> "LocalConversation": ...
69
+
70
+ @overload
71
+ def __new__(
72
+ cls: type[Self],
73
+ agent: AgentBase,
74
+ *,
75
+ workspace: RemoteWorkspace,
76
+ conversation_id: ConversationID | None = None,
77
+ callbacks: list[ConversationCallbackType] | None = None,
78
+ token_callbacks: list[ConversationTokenCallbackType] | None = None,
79
+ max_iteration_per_run: int = 500,
80
+ stuck_detection: bool = True,
81
+ stuck_detection_thresholds: (
82
+ StuckDetectionThresholds | Mapping[str, int] | None
83
+ ) = None,
84
+ visualizer: (
85
+ type[ConversationVisualizerBase] | ConversationVisualizerBase | None
86
+ ) = DefaultConversationVisualizer,
87
+ secrets: dict[str, SecretValue] | dict[str, str] | None = None,
88
+ ) -> "RemoteConversation": ...
89
+
90
+ def __new__(
91
+ cls: type[Self],
92
+ agent: AgentBase,
93
+ *,
94
+ workspace: str | Path | LocalWorkspace | RemoteWorkspace = "workspace/project",
95
+ persistence_dir: str | Path | None = None,
96
+ conversation_id: ConversationID | None = None,
97
+ callbacks: list[ConversationCallbackType] | None = None,
98
+ token_callbacks: list[ConversationTokenCallbackType] | None = None,
99
+ max_iteration_per_run: int = 500,
100
+ stuck_detection: bool = True,
101
+ stuck_detection_thresholds: (
102
+ StuckDetectionThresholds | Mapping[str, int] | None
103
+ ) = None,
104
+ visualizer: (
105
+ type[ConversationVisualizerBase] | ConversationVisualizerBase | None
106
+ ) = DefaultConversationVisualizer,
107
+ secrets: dict[str, SecretValue] | dict[str, str] | None = None,
108
+ ) -> BaseConversation:
109
+ from openhands.sdk.conversation.impl.local_conversation import LocalConversation
110
+ from openhands.sdk.conversation.impl.remote_conversation import (
111
+ RemoteConversation,
112
+ )
113
+
114
+ if isinstance(workspace, RemoteWorkspace):
115
+ # For RemoteConversation, persistence_dir should not be used
116
+ # Only check if it was explicitly set to something other than the default
117
+ if persistence_dir is not None:
118
+ raise ValueError(
119
+ "persistence_dir should not be set when using RemoteConversation"
120
+ )
121
+ return RemoteConversation(
122
+ agent=agent,
123
+ conversation_id=conversation_id,
124
+ callbacks=callbacks,
125
+ token_callbacks=token_callbacks,
126
+ max_iteration_per_run=max_iteration_per_run,
127
+ stuck_detection=stuck_detection,
128
+ stuck_detection_thresholds=stuck_detection_thresholds,
129
+ visualizer=visualizer,
130
+ workspace=workspace,
131
+ secrets=secrets,
132
+ )
133
+
134
+ return LocalConversation(
135
+ agent=agent,
136
+ conversation_id=conversation_id,
137
+ callbacks=callbacks,
138
+ token_callbacks=token_callbacks,
139
+ max_iteration_per_run=max_iteration_per_run,
140
+ stuck_detection=stuck_detection,
141
+ stuck_detection_thresholds=stuck_detection_thresholds,
142
+ visualizer=visualizer,
143
+ workspace=workspace,
144
+ persistence_dir=persistence_dir,
145
+ secrets=secrets,
146
+ )
@@ -0,0 +1,85 @@
1
+ from typing import Any
2
+
3
+ from pydantic import BaseModel, Field, PrivateAttr, model_serializer
4
+
5
+ from openhands.sdk.llm.llm_registry import RegistryEvent
6
+ from openhands.sdk.llm.utils.metrics import Metrics
7
+ from openhands.sdk.logger import get_logger
8
+
9
+
10
+ logger = get_logger(__name__)
11
+
12
+
13
+ class ConversationStats(BaseModel):
14
+ """Track per-LLM usage metrics observed during conversations."""
15
+
16
+ usage_to_metrics: dict[str, Metrics] = Field(
17
+ default_factory=dict,
18
+ description="Active usage metrics tracked by the registry.",
19
+ )
20
+
21
+ _restored_usage_ids: set[str] = PrivateAttr(default_factory=set)
22
+
23
+ @model_serializer(mode="wrap")
24
+ def _serialize_with_context(self, serializer: Any, info: Any) -> dict[str, Any]:
25
+ """Serialize metrics based on context.
26
+
27
+ By default, preserves full metrics history including costs,
28
+ response_latencies, and token_usages lists for persistence.
29
+
30
+ When context={'use_snapshot': True} is passed, converts Metrics to
31
+ MetricsSnapshot format to minimize payload size for network transmission.
32
+
33
+ Args:
34
+ serializer: Pydantic's default serializer
35
+ info: Serialization info containing context
36
+
37
+ Returns:
38
+ Dictionary with metrics serialized based on context
39
+ """
40
+ # Get the default serialization
41
+ data = serializer(self)
42
+
43
+ # Check if we should use snapshot serialization
44
+ context = info.context if info else None
45
+ use_snapshot = context.get("use_snapshot", False) if context else False
46
+
47
+ if use_snapshot and "usage_to_metrics" in data:
48
+ # Replace each Metrics with its snapshot
49
+ usage_to_snapshots = {}
50
+ for usage_id, metrics in self.usage_to_metrics.items():
51
+ snapshot = metrics.get_snapshot()
52
+ usage_to_snapshots[usage_id] = snapshot.model_dump()
53
+
54
+ data["usage_to_metrics"] = usage_to_snapshots
55
+
56
+ return data
57
+
58
+ def get_combined_metrics(self) -> Metrics:
59
+ total_metrics = Metrics()
60
+ for metrics in self.usage_to_metrics.values():
61
+ total_metrics.merge(metrics)
62
+ return total_metrics
63
+
64
+ def get_metrics_for_usage(self, usage_id: str) -> Metrics:
65
+ if usage_id not in self.usage_to_metrics:
66
+ raise Exception(f"LLM usage does not exist {usage_id}")
67
+
68
+ return self.usage_to_metrics[usage_id]
69
+
70
+ def register_llm(self, event: RegistryEvent):
71
+ # Listen for LLM creations and track their metrics
72
+ llm = event.llm
73
+ usage_id = llm.usage_id
74
+
75
+ # Usage costs exist but have not been restored yet
76
+ if (
77
+ usage_id in self.usage_to_metrics
78
+ and usage_id not in self._restored_usage_ids
79
+ ):
80
+ llm.restore_metrics(self.usage_to_metrics[usage_id])
81
+ self._restored_usage_ids.add(usage_id)
82
+
83
+ # Usage is new, track its metrics
84
+ if usage_id not in self.usage_to_metrics and llm.metrics:
85
+ self.usage_to_metrics[usage_id] = llm.metrics
@@ -0,0 +1,157 @@
1
+ # state.py
2
+ import operator
3
+ from collections.abc import Iterator
4
+ from typing import SupportsIndex, overload
5
+
6
+ from openhands.sdk.conversation.events_list_base import EventsListBase
7
+ from openhands.sdk.conversation.persistence_const import (
8
+ EVENT_FILE_PATTERN,
9
+ EVENT_NAME_RE,
10
+ EVENTS_DIR,
11
+ )
12
+ from openhands.sdk.event import Event, EventID
13
+ from openhands.sdk.io import FileStore
14
+ from openhands.sdk.logger import get_logger
15
+
16
+
17
+ logger = get_logger(__name__)
18
+
19
+
20
+ class EventLog(EventsListBase):
21
+ _fs: FileStore
22
+ _dir: str
23
+ _length: int
24
+
25
+ def __init__(self, fs: FileStore, dir_path: str = EVENTS_DIR) -> None:
26
+ self._fs = fs
27
+ self._dir = dir_path
28
+ self._id_to_idx: dict[EventID, int] = {}
29
+ self._idx_to_id: dict[int, EventID] = {}
30
+ self._length = self._scan_and_build_index()
31
+
32
+ def get_index(self, event_id: EventID) -> int:
33
+ """Return the integer index for a given event_id."""
34
+ try:
35
+ return self._id_to_idx[event_id]
36
+ except KeyError:
37
+ raise KeyError(f"Unknown event_id: {event_id}")
38
+
39
+ def get_id(self, idx: int) -> EventID:
40
+ """Return the event_id for a given index."""
41
+ if idx < 0:
42
+ idx += self._length
43
+ if idx < 0 or idx >= self._length:
44
+ raise IndexError("Event index out of range")
45
+ return self._idx_to_id[idx]
46
+
47
+ @overload
48
+ def __getitem__(self, idx: int) -> Event: ...
49
+
50
+ @overload
51
+ def __getitem__(self, idx: slice) -> list[Event]: ...
52
+
53
+ def __getitem__(self, idx: SupportsIndex | slice) -> Event | list[Event]:
54
+ if isinstance(idx, slice):
55
+ start, stop, step = idx.indices(self._length)
56
+ return [self._get_single_item(i) for i in range(start, stop, step)]
57
+ # idx is int-like (SupportsIndex)
58
+ return self._get_single_item(idx)
59
+
60
+ def _get_single_item(self, idx: SupportsIndex) -> Event:
61
+ i = operator.index(idx)
62
+ if i < 0:
63
+ i += self._length
64
+ if i < 0 or i >= self._length:
65
+ raise IndexError("Event index out of range")
66
+ txt = self._fs.read(self._path(i))
67
+ if not txt:
68
+ raise FileNotFoundError(f"Missing event file: {self._path(i)}")
69
+ return Event.model_validate_json(txt)
70
+
71
+ def __iter__(self) -> Iterator[Event]:
72
+ for i in range(self._length):
73
+ txt = self._fs.read(self._path(i))
74
+ if not txt:
75
+ continue
76
+ evt = Event.model_validate_json(txt)
77
+ evt_id = evt.id
78
+ # only backfill mapping if missing
79
+ if i not in self._idx_to_id:
80
+ self._idx_to_id[i] = evt_id
81
+ self._id_to_idx.setdefault(evt_id, i)
82
+ yield evt
83
+
84
+ def append(self, event: Event) -> None:
85
+ 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}"
91
+ )
92
+
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
98
+
99
+ def __len__(self) -> int:
100
+ return self._length
101
+
102
+ def _path(self, idx: int, *, event_id: EventID | None = None) -> str:
103
+ return f"{self._dir}/{
104
+ EVENT_FILE_PATTERN.format(
105
+ idx=idx, event_id=event_id or self._idx_to_id[idx]
106
+ )
107
+ }"
108
+
109
+ def _scan_and_build_index(self) -> int:
110
+ try:
111
+ paths = self._fs.list(self._dir)
112
+ except Exception:
113
+ self._id_to_idx.clear()
114
+ self._idx_to_id.clear()
115
+ return 0
116
+
117
+ by_idx: dict[int, EventID] = {}
118
+ for p in paths:
119
+ name = p.rsplit("/", 1)[-1]
120
+ m = EVENT_NAME_RE.match(name)
121
+ if m:
122
+ idx = int(m.group("idx"))
123
+ evt_id = m.group("event_id")
124
+ by_idx[idx] = evt_id
125
+ else:
126
+ logger.warning(f"Unrecognized event file name: {name}")
127
+
128
+ if not by_idx:
129
+ self._id_to_idx.clear()
130
+ self._idx_to_id.clear()
131
+ return 0
132
+
133
+ n = 0
134
+ while True:
135
+ if n not in by_idx:
136
+ if any(i > n for i in by_idx.keys()):
137
+ logger.warning(
138
+ "Event index gap detected: "
139
+ f"expect next index {n} but got {sorted(by_idx.keys())}"
140
+ )
141
+ break
142
+ n += 1
143
+
144
+ self._id_to_idx.clear()
145
+ self._idx_to_id.clear()
146
+ for i in range(n):
147
+ evt_id = by_idx[i]
148
+ self._idx_to_id[i] = evt_id
149
+ if evt_id in self._id_to_idx:
150
+ logger.warning(
151
+ f"Duplicate event ID '{evt_id}' found during scan. "
152
+ f"Keeping first occurrence at index {self._id_to_idx[evt_id]}, "
153
+ f"ignoring duplicate at index {i}"
154
+ )
155
+ else:
156
+ self._id_to_idx[evt_id] = i
157
+ return n
@@ -0,0 +1,17 @@
1
+ from abc import ABC, abstractmethod
2
+ from collections.abc import Sequence
3
+
4
+ from openhands.sdk.event import Event
5
+
6
+
7
+ class EventsListBase(Sequence[Event], ABC):
8
+ """Abstract base class for event lists that can be appended to.
9
+
10
+ This provides a common interface for both local EventLog and remote
11
+ RemoteEventsList implementations, avoiding circular imports in protocols.
12
+ """
13
+
14
+ @abstractmethod
15
+ def append(self, event: Event) -> None:
16
+ """Add a new event to the list."""
17
+ ...
@@ -0,0 +1,50 @@
1
+ from openhands.sdk.conversation.types import ConversationID
2
+
3
+
4
+ ISSUE_URL = "https://github.com/OpenHands/software-agent-sdk/issues/new"
5
+
6
+
7
+ class ConversationRunError(RuntimeError):
8
+ """Raised when a conversation run fails.
9
+
10
+ Carries the conversation_id and persistence_dir to make resuming/debugging
11
+ easier while preserving the original exception via exception chaining.
12
+ """
13
+
14
+ conversation_id: ConversationID
15
+ persistence_dir: str | None
16
+ original_exception: BaseException
17
+
18
+ def __init__(
19
+ self,
20
+ conversation_id: ConversationID,
21
+ original_exception: BaseException,
22
+ persistence_dir: str | None = None,
23
+ message: str | None = None,
24
+ ) -> None:
25
+ self.conversation_id = conversation_id
26
+ self.persistence_dir = persistence_dir
27
+ self.original_exception = original_exception
28
+ default_msg = self._build_error_message(
29
+ conversation_id, original_exception, persistence_dir
30
+ )
31
+ super().__init__(message or default_msg)
32
+
33
+ @staticmethod
34
+ def _build_error_message(
35
+ conversation_id: ConversationID,
36
+ original_exception: BaseException,
37
+ persistence_dir: str | None,
38
+ ) -> str:
39
+ """Build a detailed error message with debugging information."""
40
+ lines = [
41
+ f"Conversation run failed for id={conversation_id}: {original_exception}",
42
+ ]
43
+
44
+ if persistence_dir:
45
+ lines.append(f"\nConversation logs are stored at: {persistence_dir}")
46
+ lines.append("\nTo help debug this issue, please file a bug report at:")
47
+ lines.append(f" {ISSUE_URL}")
48
+ lines.append("and attach the conversation logs from the directory above.")
49
+
50
+ return "\n".join(lines)
@@ -0,0 +1,133 @@
1
+ """
2
+ FIFO Lock implementation that guarantees first-in-first-out access ordering.
3
+
4
+ This provides fair lock access where threads acquire the lock in the exact order
5
+ they requested it, preventing starvation that can occur with standard RLock.
6
+ """
7
+
8
+ import threading
9
+ import time
10
+ from collections import deque
11
+ from typing import Any, Self
12
+
13
+
14
+ class FIFOLock:
15
+ """
16
+ A reentrant lock that guarantees FIFO (first-in-first-out) access ordering.
17
+
18
+ Unlike Python's standard RLock, this lock ensures that threads acquire
19
+ the lock in the exact order they requested it, providing fairness and
20
+ preventing lock starvation.
21
+
22
+ Features:
23
+ - Reentrant: Same thread can acquire multiple times
24
+ - FIFO ordering: Threads get lock in request order
25
+ - Context manager support: Use with 'with' statement
26
+ - Thread-safe: Safe for concurrent access
27
+ """
28
+
29
+ _mutex: threading.Lock
30
+ _count: int
31
+
32
+ def __init__(self) -> None:
33
+ self._mutex = threading.Lock() # Protects internal state
34
+ self._waiters: deque[threading.Condition] = (
35
+ deque()
36
+ ) # FIFO queue of waiting threads
37
+ self._owner: int | None = None # Current lock owner thread ID
38
+ self._count = 0 # Reentrancy counter
39
+
40
+ def acquire(self, blocking: bool = True, timeout: float = -1) -> bool:
41
+ """
42
+ Acquire the lock.
43
+
44
+ Args:
45
+ blocking: If True, block until lock is acquired. If False, return
46
+ immediately.
47
+ timeout: Maximum time to wait for lock (ignored if blocking=False).
48
+ -1 means wait indefinitely.
49
+
50
+ Returns:
51
+ True if lock was acquired, False otherwise.
52
+ """
53
+ ident = threading.get_ident()
54
+ start = time.monotonic()
55
+
56
+ with self._mutex:
57
+ # Reentrant case
58
+ if self._owner == ident:
59
+ self._count += 1
60
+ return True
61
+
62
+ if self._owner is None and not self._waiters:
63
+ self._owner = ident
64
+ self._count = 1
65
+ return True
66
+
67
+ if not blocking:
68
+ # Give up immediately
69
+ return False
70
+
71
+ # Add to wait queue
72
+ me = threading.Condition(self._mutex)
73
+ self._waiters.append(me)
74
+
75
+ while True:
76
+ # If I'm at the front of the queue and nobody owns it → acquire
77
+ if self._waiters[0] is me and self._owner is None:
78
+ self._waiters.popleft()
79
+ self._owner = ident
80
+ self._count = 1
81
+ return True
82
+
83
+ if timeout >= 0:
84
+ remaining = timeout - (time.monotonic() - start)
85
+ if remaining <= 0:
86
+ self._waiters.remove(me)
87
+ return False
88
+ me.wait(remaining)
89
+ else:
90
+ me.wait()
91
+
92
+ def release(self) -> None:
93
+ """
94
+ Release the lock.
95
+
96
+ Raises:
97
+ RuntimeError: If the current thread doesn't own the lock.
98
+ """
99
+ ident = threading.get_ident()
100
+ with self._mutex:
101
+ if self._owner != ident:
102
+ raise RuntimeError("Cannot release lock not owned by current thread")
103
+ assert self._count >= 1, (
104
+ "When releasing the resource, the count must be >= 1"
105
+ )
106
+ self._count -= 1
107
+ if self._count == 0:
108
+ self._owner = None
109
+ if self._waiters:
110
+ self._waiters[0].notify()
111
+
112
+ def __enter__(self: Self) -> Self:
113
+ """Context manager entry."""
114
+ self.acquire()
115
+ return self
116
+
117
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
118
+ """Context manager exit."""
119
+ self.release()
120
+
121
+ def locked(self) -> bool:
122
+ """
123
+ Return True if the lock is currently held by any thread.
124
+ """
125
+ with self._mutex:
126
+ return self._owner is not None
127
+
128
+ def owned(self) -> bool:
129
+ """
130
+ Return True if the lock is currently held by the calling thread.
131
+ """
132
+ with self._mutex:
133
+ return self._owner == threading.get_ident()
@@ -0,0 +1,5 @@
1
+ from openhands.sdk.conversation.impl.local_conversation import LocalConversation
2
+ from openhands.sdk.conversation.impl.remote_conversation import RemoteConversation
3
+
4
+
5
+ __all__ = ["LocalConversation", "RemoteConversation"]