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.
- openhands/sdk/__init__.py +111 -0
- openhands/sdk/agent/__init__.py +8 -0
- openhands/sdk/agent/agent.py +607 -0
- openhands/sdk/agent/base.py +454 -0
- openhands/sdk/agent/prompts/in_context_learning_example.j2 +169 -0
- openhands/sdk/agent/prompts/in_context_learning_example_suffix.j2 +3 -0
- openhands/sdk/agent/prompts/model_specific/anthropic_claude.j2 +3 -0
- openhands/sdk/agent/prompts/model_specific/google_gemini.j2 +1 -0
- openhands/sdk/agent/prompts/model_specific/openai_gpt/gpt-5-codex.j2 +3 -0
- openhands/sdk/agent/prompts/model_specific/openai_gpt/gpt-5.j2 +3 -0
- openhands/sdk/agent/prompts/security_policy.j2 +22 -0
- openhands/sdk/agent/prompts/security_risk_assessment.j2 +21 -0
- openhands/sdk/agent/prompts/self_documentation.j2 +15 -0
- openhands/sdk/agent/prompts/system_prompt.j2 +132 -0
- openhands/sdk/agent/prompts/system_prompt_interactive.j2 +14 -0
- openhands/sdk/agent/prompts/system_prompt_long_horizon.j2 +40 -0
- openhands/sdk/agent/prompts/system_prompt_planning.j2 +40 -0
- openhands/sdk/agent/prompts/system_prompt_tech_philosophy.j2 +122 -0
- openhands/sdk/agent/utils.py +223 -0
- openhands/sdk/context/__init__.py +28 -0
- openhands/sdk/context/agent_context.py +240 -0
- openhands/sdk/context/condenser/__init__.py +18 -0
- openhands/sdk/context/condenser/base.py +95 -0
- openhands/sdk/context/condenser/llm_summarizing_condenser.py +89 -0
- openhands/sdk/context/condenser/no_op_condenser.py +13 -0
- openhands/sdk/context/condenser/pipeline_condenser.py +55 -0
- openhands/sdk/context/condenser/prompts/summarizing_prompt.j2 +59 -0
- openhands/sdk/context/prompts/__init__.py +6 -0
- openhands/sdk/context/prompts/prompt.py +114 -0
- openhands/sdk/context/prompts/templates/ask_agent_template.j2 +11 -0
- openhands/sdk/context/prompts/templates/skill_knowledge_info.j2 +8 -0
- openhands/sdk/context/prompts/templates/system_message_suffix.j2 +32 -0
- openhands/sdk/context/skills/__init__.py +28 -0
- openhands/sdk/context/skills/exceptions.py +11 -0
- openhands/sdk/context/skills/skill.py +630 -0
- openhands/sdk/context/skills/trigger.py +36 -0
- openhands/sdk/context/skills/types.py +48 -0
- openhands/sdk/context/view.py +306 -0
- openhands/sdk/conversation/__init__.py +40 -0
- openhands/sdk/conversation/base.py +281 -0
- openhands/sdk/conversation/conversation.py +146 -0
- openhands/sdk/conversation/conversation_stats.py +85 -0
- openhands/sdk/conversation/event_store.py +157 -0
- openhands/sdk/conversation/events_list_base.py +17 -0
- openhands/sdk/conversation/exceptions.py +50 -0
- openhands/sdk/conversation/fifo_lock.py +133 -0
- openhands/sdk/conversation/impl/__init__.py +5 -0
- openhands/sdk/conversation/impl/local_conversation.py +620 -0
- openhands/sdk/conversation/impl/remote_conversation.py +883 -0
- openhands/sdk/conversation/persistence_const.py +9 -0
- openhands/sdk/conversation/response_utils.py +41 -0
- openhands/sdk/conversation/secret_registry.py +126 -0
- openhands/sdk/conversation/serialization_diff.py +0 -0
- openhands/sdk/conversation/state.py +352 -0
- openhands/sdk/conversation/stuck_detector.py +311 -0
- openhands/sdk/conversation/title_utils.py +191 -0
- openhands/sdk/conversation/types.py +45 -0
- openhands/sdk/conversation/visualizer/__init__.py +12 -0
- openhands/sdk/conversation/visualizer/base.py +67 -0
- openhands/sdk/conversation/visualizer/default.py +373 -0
- openhands/sdk/critic/__init__.py +15 -0
- openhands/sdk/critic/base.py +38 -0
- openhands/sdk/critic/impl/__init__.py +12 -0
- openhands/sdk/critic/impl/agent_finished.py +83 -0
- openhands/sdk/critic/impl/empty_patch.py +49 -0
- openhands/sdk/critic/impl/pass_critic.py +42 -0
- openhands/sdk/event/__init__.py +42 -0
- openhands/sdk/event/base.py +149 -0
- openhands/sdk/event/condenser.py +82 -0
- openhands/sdk/event/conversation_error.py +25 -0
- openhands/sdk/event/conversation_state.py +104 -0
- openhands/sdk/event/llm_completion_log.py +39 -0
- openhands/sdk/event/llm_convertible/__init__.py +20 -0
- openhands/sdk/event/llm_convertible/action.py +139 -0
- openhands/sdk/event/llm_convertible/message.py +142 -0
- openhands/sdk/event/llm_convertible/observation.py +141 -0
- openhands/sdk/event/llm_convertible/system.py +61 -0
- openhands/sdk/event/token.py +16 -0
- openhands/sdk/event/types.py +11 -0
- openhands/sdk/event/user_action.py +21 -0
- openhands/sdk/git/exceptions.py +43 -0
- openhands/sdk/git/git_changes.py +249 -0
- openhands/sdk/git/git_diff.py +129 -0
- openhands/sdk/git/models.py +21 -0
- openhands/sdk/git/utils.py +189 -0
- openhands/sdk/io/__init__.py +6 -0
- openhands/sdk/io/base.py +48 -0
- openhands/sdk/io/local.py +82 -0
- openhands/sdk/io/memory.py +54 -0
- openhands/sdk/llm/__init__.py +45 -0
- openhands/sdk/llm/exceptions/__init__.py +45 -0
- openhands/sdk/llm/exceptions/classifier.py +50 -0
- openhands/sdk/llm/exceptions/mapping.py +54 -0
- openhands/sdk/llm/exceptions/types.py +101 -0
- openhands/sdk/llm/llm.py +1140 -0
- openhands/sdk/llm/llm_registry.py +122 -0
- openhands/sdk/llm/llm_response.py +59 -0
- openhands/sdk/llm/message.py +656 -0
- openhands/sdk/llm/mixins/fn_call_converter.py +1243 -0
- openhands/sdk/llm/mixins/non_native_fc.py +93 -0
- openhands/sdk/llm/options/__init__.py +1 -0
- openhands/sdk/llm/options/chat_options.py +93 -0
- openhands/sdk/llm/options/common.py +19 -0
- openhands/sdk/llm/options/responses_options.py +67 -0
- openhands/sdk/llm/router/__init__.py +10 -0
- openhands/sdk/llm/router/base.py +117 -0
- openhands/sdk/llm/router/impl/multimodal.py +76 -0
- openhands/sdk/llm/router/impl/random.py +22 -0
- openhands/sdk/llm/streaming.py +9 -0
- openhands/sdk/llm/utils/metrics.py +312 -0
- openhands/sdk/llm/utils/model_features.py +191 -0
- openhands/sdk/llm/utils/model_info.py +90 -0
- openhands/sdk/llm/utils/model_prompt_spec.py +98 -0
- openhands/sdk/llm/utils/retry_mixin.py +128 -0
- openhands/sdk/llm/utils/telemetry.py +362 -0
- openhands/sdk/llm/utils/unverified_models.py +156 -0
- openhands/sdk/llm/utils/verified_models.py +66 -0
- openhands/sdk/logger/__init__.py +22 -0
- openhands/sdk/logger/logger.py +195 -0
- openhands/sdk/logger/rolling.py +113 -0
- openhands/sdk/mcp/__init__.py +24 -0
- openhands/sdk/mcp/client.py +76 -0
- openhands/sdk/mcp/definition.py +106 -0
- openhands/sdk/mcp/exceptions.py +19 -0
- openhands/sdk/mcp/tool.py +270 -0
- openhands/sdk/mcp/utils.py +83 -0
- openhands/sdk/observability/__init__.py +4 -0
- openhands/sdk/observability/laminar.py +166 -0
- openhands/sdk/observability/utils.py +20 -0
- openhands/sdk/py.typed +0 -0
- openhands/sdk/secret/__init__.py +19 -0
- openhands/sdk/secret/secrets.py +92 -0
- openhands/sdk/security/__init__.py +6 -0
- openhands/sdk/security/analyzer.py +111 -0
- openhands/sdk/security/confirmation_policy.py +61 -0
- openhands/sdk/security/llm_analyzer.py +29 -0
- openhands/sdk/security/risk.py +100 -0
- openhands/sdk/tool/__init__.py +34 -0
- openhands/sdk/tool/builtins/__init__.py +34 -0
- openhands/sdk/tool/builtins/finish.py +106 -0
- openhands/sdk/tool/builtins/think.py +117 -0
- openhands/sdk/tool/registry.py +161 -0
- openhands/sdk/tool/schema.py +276 -0
- openhands/sdk/tool/spec.py +39 -0
- openhands/sdk/tool/tool.py +481 -0
- openhands/sdk/utils/__init__.py +22 -0
- openhands/sdk/utils/async_executor.py +115 -0
- openhands/sdk/utils/async_utils.py +39 -0
- openhands/sdk/utils/cipher.py +68 -0
- openhands/sdk/utils/command.py +90 -0
- openhands/sdk/utils/deprecation.py +166 -0
- openhands/sdk/utils/github.py +44 -0
- openhands/sdk/utils/json.py +48 -0
- openhands/sdk/utils/models.py +570 -0
- openhands/sdk/utils/paging.py +63 -0
- openhands/sdk/utils/pydantic_diff.py +85 -0
- openhands/sdk/utils/pydantic_secrets.py +64 -0
- openhands/sdk/utils/truncate.py +117 -0
- openhands/sdk/utils/visualize.py +58 -0
- openhands/sdk/workspace/__init__.py +17 -0
- openhands/sdk/workspace/base.py +158 -0
- openhands/sdk/workspace/local.py +189 -0
- openhands/sdk/workspace/models.py +35 -0
- openhands/sdk/workspace/remote/__init__.py +8 -0
- openhands/sdk/workspace/remote/async_remote_workspace.py +149 -0
- openhands/sdk/workspace/remote/base.py +164 -0
- openhands/sdk/workspace/remote/remote_workspace_mixin.py +323 -0
- openhands/sdk/workspace/workspace.py +49 -0
- openhands_sdk-1.7.0.dist-info/METADATA +17 -0
- openhands_sdk-1.7.0.dist-info/RECORD +172 -0
- openhands_sdk-1.7.0.dist-info/WHEEL +5 -0
- 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()
|