openhands-sdk 1.7.3__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 +650 -0
- openhands/sdk/agent/base.py +457 -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 +2 -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 +228 -0
- openhands/sdk/context/__init__.py +28 -0
- openhands/sdk/context/agent_context.py +264 -0
- openhands/sdk/context/condenser/__init__.py +18 -0
- openhands/sdk/context/condenser/base.py +100 -0
- openhands/sdk/context/condenser/llm_summarizing_condenser.py +248 -0
- openhands/sdk/context/condenser/no_op_condenser.py +14 -0
- openhands/sdk/context/condenser/pipeline_condenser.py +56 -0
- openhands/sdk/context/condenser/prompts/summarizing_prompt.j2 +59 -0
- openhands/sdk/context/condenser/utils.py +149 -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 +720 -0
- openhands/sdk/context/skills/trigger.py +36 -0
- openhands/sdk/context/skills/types.py +48 -0
- openhands/sdk/context/view.py +503 -0
- openhands/sdk/conversation/__init__.py +40 -0
- openhands/sdk/conversation/base.py +281 -0
- openhands/sdk/conversation/conversation.py +152 -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 +665 -0
- openhands/sdk/conversation/impl/remote_conversation.py +956 -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 +392 -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/hooks/__init__.py +30 -0
- openhands/sdk/hooks/config.py +180 -0
- openhands/sdk/hooks/conversation_hooks.py +227 -0
- openhands/sdk/hooks/executor.py +155 -0
- openhands/sdk/hooks/manager.py +170 -0
- openhands/sdk/hooks/types.py +40 -0
- openhands/sdk/io/__init__.py +6 -0
- openhands/sdk/io/base.py +48 -0
- openhands/sdk/io/cache.py +85 -0
- openhands/sdk/io/local.py +119 -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 +1288 -0
- openhands/sdk/llm/mixins/non_native_fc.py +97 -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 +192 -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 +65 -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 +184 -0
- openhands/sdk/tool/schema.py +286 -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.3.dist-info/METADATA +17 -0
- openhands_sdk-1.7.3.dist-info/RECORD +180 -0
- openhands_sdk-1.7.3.dist-info/WHEEL +5 -0
- openhands_sdk-1.7.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from collections.abc import Iterable, Mapping
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import TYPE_CHECKING, Protocol, TypeVar, cast
|
|
5
|
+
|
|
6
|
+
from openhands.sdk.conversation.conversation_stats import ConversationStats
|
|
7
|
+
from openhands.sdk.conversation.events_list_base import EventsListBase
|
|
8
|
+
from openhands.sdk.conversation.secret_registry import SecretValue
|
|
9
|
+
from openhands.sdk.conversation.types import (
|
|
10
|
+
ConversationCallbackType,
|
|
11
|
+
ConversationID,
|
|
12
|
+
ConversationTokenCallbackType,
|
|
13
|
+
)
|
|
14
|
+
from openhands.sdk.llm.llm import LLM
|
|
15
|
+
from openhands.sdk.llm.message import Message
|
|
16
|
+
from openhands.sdk.observability.laminar import (
|
|
17
|
+
end_active_span,
|
|
18
|
+
should_enable_observability,
|
|
19
|
+
start_active_span,
|
|
20
|
+
)
|
|
21
|
+
from openhands.sdk.security.analyzer import SecurityAnalyzerBase
|
|
22
|
+
from openhands.sdk.security.confirmation_policy import (
|
|
23
|
+
ConfirmationPolicyBase,
|
|
24
|
+
NeverConfirm,
|
|
25
|
+
)
|
|
26
|
+
from openhands.sdk.workspace.base import BaseWorkspace
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from openhands.sdk.agent.base import AgentBase
|
|
31
|
+
from openhands.sdk.conversation.state import ConversationExecutionStatus
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
CallbackType = TypeVar(
|
|
35
|
+
"CallbackType",
|
|
36
|
+
ConversationCallbackType,
|
|
37
|
+
ConversationTokenCallbackType,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ConversationStateProtocol(Protocol):
|
|
42
|
+
"""Protocol defining the interface for conversation state objects."""
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def id(self) -> ConversationID:
|
|
46
|
+
"""The conversation ID."""
|
|
47
|
+
...
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def events(self) -> EventsListBase:
|
|
51
|
+
"""Access to the events list."""
|
|
52
|
+
...
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def execution_status(self) -> "ConversationExecutionStatus":
|
|
56
|
+
"""The current conversation execution status."""
|
|
57
|
+
...
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def confirmation_policy(self) -> ConfirmationPolicyBase:
|
|
61
|
+
"""The confirmation policy."""
|
|
62
|
+
...
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def security_analyzer(self) -> SecurityAnalyzerBase | None:
|
|
66
|
+
"""The security analyzer."""
|
|
67
|
+
...
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def activated_knowledge_skills(self) -> list[str]:
|
|
71
|
+
"""List of activated knowledge skills."""
|
|
72
|
+
...
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def workspace(self) -> BaseWorkspace:
|
|
76
|
+
"""The workspace for agent operations and tool execution."""
|
|
77
|
+
...
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def persistence_dir(self) -> str | None:
|
|
81
|
+
"""The persistence directory from the FileStore.
|
|
82
|
+
|
|
83
|
+
If None, it means the conversation is not being persisted.
|
|
84
|
+
"""
|
|
85
|
+
...
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def agent(self) -> "AgentBase":
|
|
89
|
+
"""The agent running in the conversation."""
|
|
90
|
+
...
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def stats(self) -> ConversationStats:
|
|
94
|
+
"""The conversation statistics."""
|
|
95
|
+
...
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class BaseConversation(ABC):
|
|
99
|
+
"""Abstract base class for conversation implementations.
|
|
100
|
+
|
|
101
|
+
This class defines the interface that all conversation implementations must follow.
|
|
102
|
+
Conversations manage the interaction between users and agents, handling message
|
|
103
|
+
exchange, execution control, and state management.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
def __init__(self) -> None:
|
|
107
|
+
"""Initialize the base conversation with span tracking."""
|
|
108
|
+
self._span_ended = False
|
|
109
|
+
|
|
110
|
+
def _start_observability_span(self, session_id: str) -> None:
|
|
111
|
+
"""Start an observability span if observability is enabled.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
session_id: The session ID to associate with the span
|
|
115
|
+
"""
|
|
116
|
+
if should_enable_observability():
|
|
117
|
+
start_active_span("conversation", session_id=session_id)
|
|
118
|
+
|
|
119
|
+
def _end_observability_span(self) -> None:
|
|
120
|
+
"""End the observability span if it hasn't been ended already."""
|
|
121
|
+
if not self._span_ended and should_enable_observability():
|
|
122
|
+
end_active_span()
|
|
123
|
+
self._span_ended = True
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
@abstractmethod
|
|
127
|
+
def id(self) -> ConversationID: ...
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
@abstractmethod
|
|
131
|
+
def state(self) -> ConversationStateProtocol: ...
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
@abstractmethod
|
|
135
|
+
def conversation_stats(self) -> ConversationStats: ...
|
|
136
|
+
|
|
137
|
+
@abstractmethod
|
|
138
|
+
def send_message(self, message: str | Message, sender: str | None = None) -> None:
|
|
139
|
+
"""Send a message to the agent.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
message: Either a string (which will be converted to a user message)
|
|
143
|
+
or a Message object
|
|
144
|
+
sender: Optional identifier of the sender. Can be used to track
|
|
145
|
+
message origin in multi-agent scenarios. For example, when
|
|
146
|
+
one agent delegates to another, the sender can be set to
|
|
147
|
+
identify which agent is sending the message.
|
|
148
|
+
"""
|
|
149
|
+
...
|
|
150
|
+
|
|
151
|
+
@abstractmethod
|
|
152
|
+
def run(self) -> None:
|
|
153
|
+
"""Execute the agent to process messages and perform actions.
|
|
154
|
+
|
|
155
|
+
This method runs the agent until it finishes processing the current
|
|
156
|
+
message or reaches the maximum iteration limit.
|
|
157
|
+
"""
|
|
158
|
+
...
|
|
159
|
+
|
|
160
|
+
@abstractmethod
|
|
161
|
+
def set_confirmation_policy(self, policy: ConfirmationPolicyBase) -> None:
|
|
162
|
+
"""Set the confirmation policy for the conversation."""
|
|
163
|
+
...
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def confirmation_policy_active(self) -> bool:
|
|
167
|
+
return not isinstance(self.state.confirmation_policy, NeverConfirm)
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def is_confirmation_mode_active(self) -> bool:
|
|
171
|
+
"""Check if confirmation mode is active.
|
|
172
|
+
|
|
173
|
+
Returns True if BOTH conditions are met:
|
|
174
|
+
1. The conversation state has a security analyzer set (not None)
|
|
175
|
+
2. The confirmation policy is active
|
|
176
|
+
|
|
177
|
+
"""
|
|
178
|
+
return (
|
|
179
|
+
self.state.security_analyzer is not None and self.confirmation_policy_active
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
@abstractmethod
|
|
183
|
+
def reject_pending_actions(
|
|
184
|
+
self, reason: str = "User rejected the action"
|
|
185
|
+
) -> None: ...
|
|
186
|
+
|
|
187
|
+
@abstractmethod
|
|
188
|
+
def pause(self) -> None: ...
|
|
189
|
+
|
|
190
|
+
@abstractmethod
|
|
191
|
+
def update_secrets(self, secrets: Mapping[str, SecretValue]) -> None: ...
|
|
192
|
+
|
|
193
|
+
@abstractmethod
|
|
194
|
+
def close(self) -> None: ...
|
|
195
|
+
|
|
196
|
+
@abstractmethod
|
|
197
|
+
def generate_title(self, llm: LLM | None = None, max_length: int = 50) -> str:
|
|
198
|
+
"""Generate a title for the conversation based on the first user message.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
llm: Optional LLM to use for title generation. If not provided,
|
|
202
|
+
uses the agent's LLM.
|
|
203
|
+
max_length: Maximum length of the generated title.
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
A generated title for the conversation.
|
|
207
|
+
|
|
208
|
+
Raises:
|
|
209
|
+
ValueError: If no user messages are found in the conversation.
|
|
210
|
+
"""
|
|
211
|
+
...
|
|
212
|
+
|
|
213
|
+
@staticmethod
|
|
214
|
+
def get_persistence_dir(
|
|
215
|
+
persistence_base_dir: str | Path, conversation_id: ConversationID
|
|
216
|
+
) -> str:
|
|
217
|
+
"""Get the persistence directory for the conversation.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
persistence_base_dir: Base directory for persistence. Can be a string
|
|
221
|
+
path or Path object.
|
|
222
|
+
conversation_id: Unique conversation ID.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
String path to the conversation-specific persistence directory.
|
|
226
|
+
Always returns a normalized string path even if a Path was provided.
|
|
227
|
+
"""
|
|
228
|
+
return str(Path(persistence_base_dir) / conversation_id.hex)
|
|
229
|
+
|
|
230
|
+
@abstractmethod
|
|
231
|
+
def ask_agent(self, question: str) -> str:
|
|
232
|
+
"""Ask the agent a simple, stateless question and get a direct LLM response.
|
|
233
|
+
|
|
234
|
+
This bypasses the normal conversation flow and does **not** modify, persist,
|
|
235
|
+
or become part of the conversation state. The request is not remembered by
|
|
236
|
+
the main agent, no events are recorded, and execution status is untouched.
|
|
237
|
+
It is also thread-safe and may be called while `conversation.run()` is
|
|
238
|
+
executing in another thread.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
question: A simple string question to ask the agent
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
A string response from the agent
|
|
245
|
+
"""
|
|
246
|
+
...
|
|
247
|
+
|
|
248
|
+
@abstractmethod
|
|
249
|
+
def condense(self) -> None:
|
|
250
|
+
"""Force condensation of the conversation history.
|
|
251
|
+
|
|
252
|
+
This method uses the existing condensation request pattern to trigger
|
|
253
|
+
condensation. It adds a CondensationRequest event to the conversation
|
|
254
|
+
and forces the agent to take a single step to process it.
|
|
255
|
+
|
|
256
|
+
The condensation will be applied immediately and will modify the conversation
|
|
257
|
+
state by adding a condensation event to the history.
|
|
258
|
+
|
|
259
|
+
Raises:
|
|
260
|
+
ValueError: If no condenser is configured or the condenser doesn't
|
|
261
|
+
handle condensation requests.
|
|
262
|
+
"""
|
|
263
|
+
...
|
|
264
|
+
|
|
265
|
+
@staticmethod
|
|
266
|
+
def compose_callbacks(callbacks: Iterable[CallbackType]) -> CallbackType:
|
|
267
|
+
"""Compose multiple callbacks into a single callback function.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
callbacks: An iterable of callback functions
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
A single callback function that calls all provided callbacks
|
|
274
|
+
"""
|
|
275
|
+
|
|
276
|
+
def composed(event) -> None:
|
|
277
|
+
for cb in callbacks:
|
|
278
|
+
if cb:
|
|
279
|
+
cb(event)
|
|
280
|
+
|
|
281
|
+
return cast(CallbackType, composed)
|
|
@@ -0,0 +1,152 @@
|
|
|
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.hooks import HookConfig
|
|
18
|
+
from openhands.sdk.logger import get_logger
|
|
19
|
+
from openhands.sdk.secret import SecretValue
|
|
20
|
+
from openhands.sdk.workspace import LocalWorkspace, RemoteWorkspace
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from openhands.sdk.conversation.impl.local_conversation import LocalConversation
|
|
25
|
+
from openhands.sdk.conversation.impl.remote_conversation import RemoteConversation
|
|
26
|
+
|
|
27
|
+
logger = get_logger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Conversation:
|
|
31
|
+
"""Factory class for creating conversation instances with OpenHands agents.
|
|
32
|
+
|
|
33
|
+
This factory automatically creates either a LocalConversation or RemoteConversation
|
|
34
|
+
based on the workspace type provided. LocalConversation runs the agent locally,
|
|
35
|
+
while RemoteConversation connects to a remote agent server.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
LocalConversation if workspace is local, RemoteConversation if workspace
|
|
39
|
+
is remote.
|
|
40
|
+
|
|
41
|
+
Example:
|
|
42
|
+
>>> from openhands.sdk import LLM, Agent, Conversation
|
|
43
|
+
>>> llm = LLM(model="claude-sonnet-4-20250514", api_key=SecretStr("key"))
|
|
44
|
+
>>> agent = Agent(llm=llm, tools=[])
|
|
45
|
+
>>> conversation = Conversation(agent=agent, workspace="./workspace")
|
|
46
|
+
>>> conversation.send_message("Hello!")
|
|
47
|
+
>>> conversation.run()
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
@overload
|
|
51
|
+
def __new__(
|
|
52
|
+
cls: type[Self],
|
|
53
|
+
agent: AgentBase,
|
|
54
|
+
*,
|
|
55
|
+
workspace: str | Path | LocalWorkspace = "workspace/project",
|
|
56
|
+
persistence_dir: str | Path | None = None,
|
|
57
|
+
conversation_id: ConversationID | None = None,
|
|
58
|
+
callbacks: list[ConversationCallbackType] | None = None,
|
|
59
|
+
token_callbacks: list[ConversationTokenCallbackType] | None = None,
|
|
60
|
+
hook_config: HookConfig | None = None,
|
|
61
|
+
max_iteration_per_run: int = 500,
|
|
62
|
+
stuck_detection: bool = True,
|
|
63
|
+
stuck_detection_thresholds: (
|
|
64
|
+
StuckDetectionThresholds | Mapping[str, int] | None
|
|
65
|
+
) = None,
|
|
66
|
+
visualizer: (
|
|
67
|
+
type[ConversationVisualizerBase] | ConversationVisualizerBase | None
|
|
68
|
+
) = DefaultConversationVisualizer,
|
|
69
|
+
secrets: dict[str, SecretValue] | dict[str, str] | None = None,
|
|
70
|
+
) -> "LocalConversation": ...
|
|
71
|
+
|
|
72
|
+
@overload
|
|
73
|
+
def __new__(
|
|
74
|
+
cls: type[Self],
|
|
75
|
+
agent: AgentBase,
|
|
76
|
+
*,
|
|
77
|
+
workspace: RemoteWorkspace,
|
|
78
|
+
conversation_id: ConversationID | None = None,
|
|
79
|
+
callbacks: list[ConversationCallbackType] | None = None,
|
|
80
|
+
token_callbacks: list[ConversationTokenCallbackType] | None = None,
|
|
81
|
+
hook_config: HookConfig | None = None,
|
|
82
|
+
max_iteration_per_run: int = 500,
|
|
83
|
+
stuck_detection: bool = True,
|
|
84
|
+
stuck_detection_thresholds: (
|
|
85
|
+
StuckDetectionThresholds | Mapping[str, int] | None
|
|
86
|
+
) = None,
|
|
87
|
+
visualizer: (
|
|
88
|
+
type[ConversationVisualizerBase] | ConversationVisualizerBase | None
|
|
89
|
+
) = DefaultConversationVisualizer,
|
|
90
|
+
secrets: dict[str, SecretValue] | dict[str, str] | None = None,
|
|
91
|
+
) -> "RemoteConversation": ...
|
|
92
|
+
|
|
93
|
+
def __new__(
|
|
94
|
+
cls: type[Self],
|
|
95
|
+
agent: AgentBase,
|
|
96
|
+
*,
|
|
97
|
+
workspace: str | Path | LocalWorkspace | RemoteWorkspace = "workspace/project",
|
|
98
|
+
persistence_dir: str | Path | None = None,
|
|
99
|
+
conversation_id: ConversationID | None = None,
|
|
100
|
+
callbacks: list[ConversationCallbackType] | None = None,
|
|
101
|
+
token_callbacks: list[ConversationTokenCallbackType] | None = None,
|
|
102
|
+
hook_config: HookConfig | None = None,
|
|
103
|
+
max_iteration_per_run: int = 500,
|
|
104
|
+
stuck_detection: bool = True,
|
|
105
|
+
stuck_detection_thresholds: (
|
|
106
|
+
StuckDetectionThresholds | Mapping[str, int] | None
|
|
107
|
+
) = None,
|
|
108
|
+
visualizer: (
|
|
109
|
+
type[ConversationVisualizerBase] | ConversationVisualizerBase | None
|
|
110
|
+
) = DefaultConversationVisualizer,
|
|
111
|
+
secrets: dict[str, SecretValue] | dict[str, str] | None = None,
|
|
112
|
+
) -> BaseConversation:
|
|
113
|
+
from openhands.sdk.conversation.impl.local_conversation import LocalConversation
|
|
114
|
+
from openhands.sdk.conversation.impl.remote_conversation import (
|
|
115
|
+
RemoteConversation,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
if isinstance(workspace, RemoteWorkspace):
|
|
119
|
+
# For RemoteConversation, persistence_dir should not be used
|
|
120
|
+
# Only check if it was explicitly set to something other than the default
|
|
121
|
+
if persistence_dir is not None:
|
|
122
|
+
raise ValueError(
|
|
123
|
+
"persistence_dir should not be set when using RemoteConversation"
|
|
124
|
+
)
|
|
125
|
+
return RemoteConversation(
|
|
126
|
+
agent=agent,
|
|
127
|
+
conversation_id=conversation_id,
|
|
128
|
+
callbacks=callbacks,
|
|
129
|
+
token_callbacks=token_callbacks,
|
|
130
|
+
hook_config=hook_config,
|
|
131
|
+
max_iteration_per_run=max_iteration_per_run,
|
|
132
|
+
stuck_detection=stuck_detection,
|
|
133
|
+
stuck_detection_thresholds=stuck_detection_thresholds,
|
|
134
|
+
visualizer=visualizer,
|
|
135
|
+
workspace=workspace,
|
|
136
|
+
secrets=secrets,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
return LocalConversation(
|
|
140
|
+
agent=agent,
|
|
141
|
+
conversation_id=conversation_id,
|
|
142
|
+
callbacks=callbacks,
|
|
143
|
+
token_callbacks=token_callbacks,
|
|
144
|
+
hook_config=hook_config,
|
|
145
|
+
max_iteration_per_run=max_iteration_per_run,
|
|
146
|
+
stuck_detection=stuck_detection,
|
|
147
|
+
stuck_detection_thresholds=stuck_detection_thresholds,
|
|
148
|
+
visualizer=visualizer,
|
|
149
|
+
workspace=workspace,
|
|
150
|
+
persistence_dir=persistence_dir,
|
|
151
|
+
secrets=secrets,
|
|
152
|
+
)
|
|
@@ -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
|
+
...
|