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,665 @@
|
|
|
1
|
+
import atexit
|
|
2
|
+
import uuid
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from openhands.sdk.agent.base import AgentBase
|
|
7
|
+
from openhands.sdk.context.prompts.prompt import render_template
|
|
8
|
+
from openhands.sdk.conversation.base import BaseConversation
|
|
9
|
+
from openhands.sdk.conversation.exceptions import ConversationRunError
|
|
10
|
+
from openhands.sdk.conversation.secret_registry import SecretValue
|
|
11
|
+
from openhands.sdk.conversation.state import (
|
|
12
|
+
ConversationExecutionStatus,
|
|
13
|
+
ConversationState,
|
|
14
|
+
)
|
|
15
|
+
from openhands.sdk.conversation.stuck_detector import StuckDetector
|
|
16
|
+
from openhands.sdk.conversation.title_utils import generate_conversation_title
|
|
17
|
+
from openhands.sdk.conversation.types import (
|
|
18
|
+
ConversationCallbackType,
|
|
19
|
+
ConversationID,
|
|
20
|
+
ConversationTokenCallbackType,
|
|
21
|
+
StuckDetectionThresholds,
|
|
22
|
+
)
|
|
23
|
+
from openhands.sdk.conversation.visualizer import (
|
|
24
|
+
ConversationVisualizerBase,
|
|
25
|
+
DefaultConversationVisualizer,
|
|
26
|
+
)
|
|
27
|
+
from openhands.sdk.event import (
|
|
28
|
+
CondensationRequest,
|
|
29
|
+
MessageEvent,
|
|
30
|
+
PauseEvent,
|
|
31
|
+
UserRejectObservation,
|
|
32
|
+
)
|
|
33
|
+
from openhands.sdk.event.conversation_error import ConversationErrorEvent
|
|
34
|
+
from openhands.sdk.hooks import HookConfig, HookEventProcessor, create_hook_callback
|
|
35
|
+
from openhands.sdk.llm import LLM, Message, TextContent
|
|
36
|
+
from openhands.sdk.llm.llm_registry import LLMRegistry
|
|
37
|
+
from openhands.sdk.logger import get_logger
|
|
38
|
+
from openhands.sdk.observability.laminar import observe
|
|
39
|
+
from openhands.sdk.security.analyzer import SecurityAnalyzerBase
|
|
40
|
+
from openhands.sdk.security.confirmation_policy import (
|
|
41
|
+
ConfirmationPolicyBase,
|
|
42
|
+
)
|
|
43
|
+
from openhands.sdk.workspace import LocalWorkspace
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
logger = get_logger(__name__)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class LocalConversation(BaseConversation):
|
|
50
|
+
agent: AgentBase
|
|
51
|
+
workspace: LocalWorkspace
|
|
52
|
+
_state: ConversationState
|
|
53
|
+
_visualizer: ConversationVisualizerBase | None
|
|
54
|
+
_on_event: ConversationCallbackType
|
|
55
|
+
_on_token: ConversationTokenCallbackType | None
|
|
56
|
+
max_iteration_per_run: int
|
|
57
|
+
_stuck_detector: StuckDetector | None
|
|
58
|
+
llm_registry: LLMRegistry
|
|
59
|
+
_cleanup_initiated: bool
|
|
60
|
+
_hook_processor: HookEventProcessor | None
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
agent: AgentBase,
|
|
65
|
+
workspace: str | Path | LocalWorkspace,
|
|
66
|
+
persistence_dir: str | Path | None = None,
|
|
67
|
+
conversation_id: ConversationID | None = None,
|
|
68
|
+
callbacks: list[ConversationCallbackType] | None = None,
|
|
69
|
+
token_callbacks: list[ConversationTokenCallbackType] | None = None,
|
|
70
|
+
hook_config: HookConfig | None = None,
|
|
71
|
+
max_iteration_per_run: int = 500,
|
|
72
|
+
stuck_detection: bool = True,
|
|
73
|
+
stuck_detection_thresholds: (
|
|
74
|
+
StuckDetectionThresholds | Mapping[str, int] | None
|
|
75
|
+
) = None,
|
|
76
|
+
visualizer: (
|
|
77
|
+
type[ConversationVisualizerBase] | ConversationVisualizerBase | None
|
|
78
|
+
) = DefaultConversationVisualizer,
|
|
79
|
+
secrets: Mapping[str, SecretValue] | None = None,
|
|
80
|
+
**_: object,
|
|
81
|
+
):
|
|
82
|
+
"""Initialize the conversation.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
agent: The agent to use for the conversation
|
|
86
|
+
workspace: Working directory for agent operations and tool execution.
|
|
87
|
+
Can be a string path, Path object, or LocalWorkspace instance.
|
|
88
|
+
persistence_dir: Directory for persisting conversation state and events.
|
|
89
|
+
Can be a string path or Path object.
|
|
90
|
+
conversation_id: Optional ID for the conversation. If provided, will
|
|
91
|
+
be used to identify the conversation. The user might want to
|
|
92
|
+
suffix their persistent filestore with this ID.
|
|
93
|
+
callbacks: Optional list of callback functions to handle events
|
|
94
|
+
token_callbacks: Optional list of callbacks invoked for streaming deltas
|
|
95
|
+
hook_config: Optional hook configuration to auto-wire session hooks
|
|
96
|
+
max_iteration_per_run: Maximum number of iterations per run
|
|
97
|
+
visualizer: Visualization configuration. Can be:
|
|
98
|
+
- ConversationVisualizerBase subclass: Class to instantiate
|
|
99
|
+
(default: ConversationVisualizer)
|
|
100
|
+
- ConversationVisualizerBase instance: Use custom visualizer
|
|
101
|
+
- None: No visualization
|
|
102
|
+
stuck_detection: Whether to enable stuck detection
|
|
103
|
+
stuck_detection_thresholds: Optional configuration for stuck detection
|
|
104
|
+
thresholds. Can be a StuckDetectionThresholds instance or
|
|
105
|
+
a dict with keys: 'action_observation', 'action_error',
|
|
106
|
+
'monologue', 'alternating_pattern'. Values are integers
|
|
107
|
+
representing the number of repetitions before triggering.
|
|
108
|
+
"""
|
|
109
|
+
super().__init__() # Initialize with span tracking
|
|
110
|
+
# Mark cleanup as initiated as early as possible to avoid races or partially
|
|
111
|
+
# initialized instances during interpreter shutdown.
|
|
112
|
+
self._cleanup_initiated = False
|
|
113
|
+
|
|
114
|
+
self.agent = agent
|
|
115
|
+
if isinstance(workspace, (str, Path)):
|
|
116
|
+
# LocalWorkspace accepts both str and Path via BeforeValidator
|
|
117
|
+
workspace = LocalWorkspace(working_dir=workspace)
|
|
118
|
+
assert isinstance(workspace, LocalWorkspace), (
|
|
119
|
+
"workspace must be a LocalWorkspace instance"
|
|
120
|
+
)
|
|
121
|
+
self.workspace = workspace
|
|
122
|
+
ws_path = Path(self.workspace.working_dir)
|
|
123
|
+
if not ws_path.exists():
|
|
124
|
+
ws_path.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
|
|
126
|
+
# Create-or-resume: factory inspects BASE_STATE to decide
|
|
127
|
+
desired_id = conversation_id or uuid.uuid4()
|
|
128
|
+
self._state = ConversationState.create(
|
|
129
|
+
id=desired_id,
|
|
130
|
+
agent=agent,
|
|
131
|
+
workspace=self.workspace,
|
|
132
|
+
persistence_dir=self.get_persistence_dir(persistence_dir, desired_id)
|
|
133
|
+
if persistence_dir
|
|
134
|
+
else None,
|
|
135
|
+
max_iterations=max_iteration_per_run,
|
|
136
|
+
stuck_detection=stuck_detection,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# Default callback: persist every event to state
|
|
140
|
+
def _default_callback(e):
|
|
141
|
+
self._state.events.append(e)
|
|
142
|
+
|
|
143
|
+
self._hook_processor = None
|
|
144
|
+
hook_callback = None
|
|
145
|
+
if hook_config is not None:
|
|
146
|
+
self._hook_processor, hook_callback = create_hook_callback(
|
|
147
|
+
hook_config=hook_config,
|
|
148
|
+
working_dir=str(self.workspace.working_dir),
|
|
149
|
+
session_id=str(desired_id),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
callback_list = list(callbacks) if callbacks else []
|
|
153
|
+
if hook_callback is not None:
|
|
154
|
+
callback_list.insert(0, hook_callback)
|
|
155
|
+
|
|
156
|
+
composed_list = callback_list + [_default_callback]
|
|
157
|
+
# Handle visualization configuration
|
|
158
|
+
if isinstance(visualizer, ConversationVisualizerBase):
|
|
159
|
+
# Use custom visualizer instance
|
|
160
|
+
self._visualizer = visualizer
|
|
161
|
+
# Initialize the visualizer with conversation state
|
|
162
|
+
self._visualizer.initialize(self._state)
|
|
163
|
+
composed_list = [self._visualizer.on_event] + composed_list
|
|
164
|
+
# visualizer should happen first for visibility
|
|
165
|
+
elif isinstance(visualizer, type) and issubclass(
|
|
166
|
+
visualizer, ConversationVisualizerBase
|
|
167
|
+
):
|
|
168
|
+
# Instantiate the visualizer class with appropriate parameters
|
|
169
|
+
self._visualizer = visualizer()
|
|
170
|
+
# Initialize with state
|
|
171
|
+
self._visualizer.initialize(self._state)
|
|
172
|
+
composed_list = [self._visualizer.on_event] + composed_list
|
|
173
|
+
# visualizer should happen first for visibility
|
|
174
|
+
else:
|
|
175
|
+
# No visualization (visualizer is None)
|
|
176
|
+
self._visualizer = None
|
|
177
|
+
|
|
178
|
+
self._on_event = BaseConversation.compose_callbacks(composed_list)
|
|
179
|
+
self._on_token = (
|
|
180
|
+
BaseConversation.compose_callbacks(token_callbacks)
|
|
181
|
+
if token_callbacks
|
|
182
|
+
else None
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
self.max_iteration_per_run = max_iteration_per_run
|
|
186
|
+
|
|
187
|
+
# Initialize stuck detector
|
|
188
|
+
if stuck_detection:
|
|
189
|
+
# Convert dict to StuckDetectionThresholds if needed
|
|
190
|
+
if isinstance(stuck_detection_thresholds, Mapping):
|
|
191
|
+
threshold_config = StuckDetectionThresholds(
|
|
192
|
+
**stuck_detection_thresholds
|
|
193
|
+
)
|
|
194
|
+
else:
|
|
195
|
+
threshold_config = stuck_detection_thresholds
|
|
196
|
+
self._stuck_detector = StuckDetector(
|
|
197
|
+
self._state,
|
|
198
|
+
thresholds=threshold_config,
|
|
199
|
+
)
|
|
200
|
+
else:
|
|
201
|
+
self._stuck_detector = None
|
|
202
|
+
|
|
203
|
+
if self._hook_processor is not None:
|
|
204
|
+
self._hook_processor.set_conversation_state(self._state)
|
|
205
|
+
self._hook_processor.run_session_start()
|
|
206
|
+
|
|
207
|
+
with self._state:
|
|
208
|
+
self.agent.init_state(self._state, on_event=self._on_event)
|
|
209
|
+
|
|
210
|
+
# Register existing llms in agent
|
|
211
|
+
self.llm_registry = LLMRegistry()
|
|
212
|
+
self.llm_registry.subscribe(self._state.stats.register_llm)
|
|
213
|
+
for llm in list(self.agent.get_all_llms()):
|
|
214
|
+
self.llm_registry.add(llm)
|
|
215
|
+
|
|
216
|
+
# Initialize secrets if provided
|
|
217
|
+
if secrets:
|
|
218
|
+
# Convert dict[str, str] to dict[str, SecretValue]
|
|
219
|
+
secret_values: dict[str, SecretValue] = {k: v for k, v in secrets.items()}
|
|
220
|
+
self.update_secrets(secret_values)
|
|
221
|
+
|
|
222
|
+
atexit.register(self.close)
|
|
223
|
+
self._start_observability_span(str(desired_id))
|
|
224
|
+
|
|
225
|
+
@property
|
|
226
|
+
def id(self) -> ConversationID:
|
|
227
|
+
"""Get the unique ID of the conversation."""
|
|
228
|
+
return self._state.id
|
|
229
|
+
|
|
230
|
+
@property
|
|
231
|
+
def state(self) -> ConversationState:
|
|
232
|
+
"""Get the conversation state.
|
|
233
|
+
|
|
234
|
+
It returns a protocol that has a subset of ConversationState methods
|
|
235
|
+
and properties. We will have the ability to access the same properties
|
|
236
|
+
of ConversationState on a remote conversation object.
|
|
237
|
+
But we won't be able to access methods that mutate the state.
|
|
238
|
+
"""
|
|
239
|
+
return self._state
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def conversation_stats(self):
|
|
243
|
+
return self._state.stats
|
|
244
|
+
|
|
245
|
+
@property
|
|
246
|
+
def stuck_detector(self) -> StuckDetector | None:
|
|
247
|
+
"""Get the stuck detector instance if enabled."""
|
|
248
|
+
return self._stuck_detector
|
|
249
|
+
|
|
250
|
+
@observe(name="conversation.send_message")
|
|
251
|
+
def send_message(self, message: str | Message, sender: str | None = None) -> None:
|
|
252
|
+
"""Send a message to the agent.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
message: Either a string (which will be converted to a user message)
|
|
256
|
+
or a Message object
|
|
257
|
+
sender: Optional identifier of the sender. Can be used to track
|
|
258
|
+
message origin in multi-agent scenarios. For example, when
|
|
259
|
+
one agent delegates to another, the sender can be set to
|
|
260
|
+
identify which agent is sending the message.
|
|
261
|
+
"""
|
|
262
|
+
# Convert string to Message if needed
|
|
263
|
+
if isinstance(message, str):
|
|
264
|
+
message = Message(role="user", content=[TextContent(text=message)])
|
|
265
|
+
|
|
266
|
+
assert message.role == "user", (
|
|
267
|
+
"Only user messages are allowed to be sent to the agent."
|
|
268
|
+
)
|
|
269
|
+
with self._state:
|
|
270
|
+
if self._state.execution_status == ConversationExecutionStatus.FINISHED:
|
|
271
|
+
self._state.execution_status = (
|
|
272
|
+
ConversationExecutionStatus.IDLE
|
|
273
|
+
) # now we have a new message
|
|
274
|
+
|
|
275
|
+
# TODO: We should add test cases for all these scenarios
|
|
276
|
+
activated_skill_names: list[str] = []
|
|
277
|
+
extended_content: list[TextContent] = []
|
|
278
|
+
|
|
279
|
+
# Handle per-turn user message (i.e., knowledge agent trigger)
|
|
280
|
+
if self.agent.agent_context:
|
|
281
|
+
ctx = self.agent.agent_context.get_user_message_suffix(
|
|
282
|
+
user_message=message,
|
|
283
|
+
# We skip skills that were already activated
|
|
284
|
+
skip_skill_names=self._state.activated_knowledge_skills,
|
|
285
|
+
)
|
|
286
|
+
# TODO(calvin): we need to update
|
|
287
|
+
# self._state.activated_knowledge_skills
|
|
288
|
+
# so condenser can work
|
|
289
|
+
if ctx:
|
|
290
|
+
content, activated_skill_names = ctx
|
|
291
|
+
logger.debug(
|
|
292
|
+
f"Got augmented user message content: {content}, "
|
|
293
|
+
f"activated skills: {activated_skill_names}"
|
|
294
|
+
)
|
|
295
|
+
extended_content.append(content)
|
|
296
|
+
self._state.activated_knowledge_skills.extend(activated_skill_names)
|
|
297
|
+
|
|
298
|
+
user_msg_event = MessageEvent(
|
|
299
|
+
source="user",
|
|
300
|
+
llm_message=message,
|
|
301
|
+
activated_skills=activated_skill_names,
|
|
302
|
+
extended_content=extended_content,
|
|
303
|
+
sender=sender,
|
|
304
|
+
)
|
|
305
|
+
self._on_event(user_msg_event)
|
|
306
|
+
|
|
307
|
+
@observe(name="conversation.run")
|
|
308
|
+
def run(self) -> None:
|
|
309
|
+
"""Runs the conversation until the agent finishes.
|
|
310
|
+
|
|
311
|
+
In confirmation mode:
|
|
312
|
+
- First call: creates actions but doesn't execute them, stops and waits
|
|
313
|
+
- Second call: executes pending actions (implicit confirmation)
|
|
314
|
+
|
|
315
|
+
In normal mode:
|
|
316
|
+
- Creates and executes actions immediately
|
|
317
|
+
|
|
318
|
+
Can be paused between steps
|
|
319
|
+
"""
|
|
320
|
+
|
|
321
|
+
with self._state:
|
|
322
|
+
if self._state.execution_status in [
|
|
323
|
+
ConversationExecutionStatus.IDLE,
|
|
324
|
+
ConversationExecutionStatus.PAUSED,
|
|
325
|
+
ConversationExecutionStatus.ERROR,
|
|
326
|
+
]:
|
|
327
|
+
self._state.execution_status = ConversationExecutionStatus.RUNNING
|
|
328
|
+
|
|
329
|
+
iteration = 0
|
|
330
|
+
try:
|
|
331
|
+
while True:
|
|
332
|
+
logger.debug(f"Conversation run iteration {iteration}")
|
|
333
|
+
with self._state:
|
|
334
|
+
# Pause attempts to acquire the state lock
|
|
335
|
+
# Before value can be modified step can be taken
|
|
336
|
+
# Ensure step conditions are checked when lock is already acquired
|
|
337
|
+
if self._state.execution_status in [
|
|
338
|
+
ConversationExecutionStatus.FINISHED,
|
|
339
|
+
ConversationExecutionStatus.PAUSED,
|
|
340
|
+
ConversationExecutionStatus.STUCK,
|
|
341
|
+
]:
|
|
342
|
+
break
|
|
343
|
+
|
|
344
|
+
# Check for stuck patterns if enabled
|
|
345
|
+
if self._stuck_detector:
|
|
346
|
+
is_stuck = self._stuck_detector.is_stuck()
|
|
347
|
+
|
|
348
|
+
if is_stuck:
|
|
349
|
+
logger.warning("Stuck pattern detected.")
|
|
350
|
+
self._state.execution_status = (
|
|
351
|
+
ConversationExecutionStatus.STUCK
|
|
352
|
+
)
|
|
353
|
+
continue
|
|
354
|
+
|
|
355
|
+
# clear the flag before calling agent.step() (user approved)
|
|
356
|
+
if (
|
|
357
|
+
self._state.execution_status
|
|
358
|
+
== ConversationExecutionStatus.WAITING_FOR_CONFIRMATION
|
|
359
|
+
):
|
|
360
|
+
self._state.execution_status = (
|
|
361
|
+
ConversationExecutionStatus.RUNNING
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
self.agent.step(
|
|
365
|
+
self, on_event=self._on_event, on_token=self._on_token
|
|
366
|
+
)
|
|
367
|
+
iteration += 1
|
|
368
|
+
|
|
369
|
+
# Check for non-finished terminal conditions
|
|
370
|
+
# Note: We intentionally do NOT check for FINISHED status here.
|
|
371
|
+
# This allows concurrent user messages to be processed:
|
|
372
|
+
# 1. Agent finishes and sets status to FINISHED
|
|
373
|
+
# 2. User sends message concurrently via send_message()
|
|
374
|
+
# 3. send_message() waits for FIFO lock, then sets status to IDLE
|
|
375
|
+
# 4. Run loop continues to next iteration and processes the message
|
|
376
|
+
# 5. Without this design, concurrent messages would be lost
|
|
377
|
+
if (
|
|
378
|
+
self.state.execution_status
|
|
379
|
+
== ConversationExecutionStatus.WAITING_FOR_CONFIRMATION
|
|
380
|
+
):
|
|
381
|
+
break
|
|
382
|
+
|
|
383
|
+
if iteration >= self.max_iteration_per_run:
|
|
384
|
+
error_msg = (
|
|
385
|
+
f"Agent reached maximum iterations limit "
|
|
386
|
+
f"({self.max_iteration_per_run})."
|
|
387
|
+
)
|
|
388
|
+
logger.error(error_msg)
|
|
389
|
+
self._state.execution_status = ConversationExecutionStatus.ERROR
|
|
390
|
+
self._on_event(
|
|
391
|
+
ConversationErrorEvent(
|
|
392
|
+
source="environment",
|
|
393
|
+
code="MaxIterationsReached",
|
|
394
|
+
detail=error_msg,
|
|
395
|
+
)
|
|
396
|
+
)
|
|
397
|
+
break
|
|
398
|
+
except Exception as e:
|
|
399
|
+
self._state.execution_status = ConversationExecutionStatus.ERROR
|
|
400
|
+
|
|
401
|
+
# Add an error event
|
|
402
|
+
self._on_event(
|
|
403
|
+
ConversationErrorEvent(
|
|
404
|
+
source="environment",
|
|
405
|
+
code=e.__class__.__name__,
|
|
406
|
+
detail=str(e),
|
|
407
|
+
)
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
# Re-raise with conversation id and persistence dir for better UX
|
|
411
|
+
raise ConversationRunError(
|
|
412
|
+
self._state.id, e, persistence_dir=self._state.persistence_dir
|
|
413
|
+
) from e
|
|
414
|
+
|
|
415
|
+
def set_confirmation_policy(self, policy: ConfirmationPolicyBase) -> None:
|
|
416
|
+
"""Set the confirmation policy and store it in conversation state."""
|
|
417
|
+
with self._state:
|
|
418
|
+
self._state.confirmation_policy = policy
|
|
419
|
+
logger.info(f"Confirmation policy set to: {policy}")
|
|
420
|
+
|
|
421
|
+
def reject_pending_actions(self, reason: str = "User rejected the action") -> None:
|
|
422
|
+
"""Reject all pending actions from the agent.
|
|
423
|
+
|
|
424
|
+
This is a non-invasive method to reject actions between run() calls.
|
|
425
|
+
Also clears the agent_waiting_for_confirmation flag.
|
|
426
|
+
"""
|
|
427
|
+
pending_actions = ConversationState.get_unmatched_actions(self._state.events)
|
|
428
|
+
|
|
429
|
+
with self._state:
|
|
430
|
+
# Always clear the agent_waiting_for_confirmation flag
|
|
431
|
+
if (
|
|
432
|
+
self._state.execution_status
|
|
433
|
+
== ConversationExecutionStatus.WAITING_FOR_CONFIRMATION
|
|
434
|
+
):
|
|
435
|
+
self._state.execution_status = ConversationExecutionStatus.IDLE
|
|
436
|
+
|
|
437
|
+
if not pending_actions:
|
|
438
|
+
logger.warning("No pending actions to reject")
|
|
439
|
+
return
|
|
440
|
+
|
|
441
|
+
for action_event in pending_actions:
|
|
442
|
+
# Create rejection observation
|
|
443
|
+
rejection_event = UserRejectObservation(
|
|
444
|
+
action_id=action_event.id,
|
|
445
|
+
tool_name=action_event.tool_name,
|
|
446
|
+
tool_call_id=action_event.tool_call_id,
|
|
447
|
+
rejection_reason=reason,
|
|
448
|
+
)
|
|
449
|
+
self._on_event(rejection_event)
|
|
450
|
+
logger.info(f"Rejected pending action: {action_event} - {reason}")
|
|
451
|
+
|
|
452
|
+
def pause(self) -> None:
|
|
453
|
+
"""Pause agent execution.
|
|
454
|
+
|
|
455
|
+
This method can be called from any thread to request that the agent
|
|
456
|
+
pause execution. The pause will take effect at the next iteration
|
|
457
|
+
of the run loop (between agent steps).
|
|
458
|
+
|
|
459
|
+
Note: If called during an LLM completion, the pause will not take
|
|
460
|
+
effect until the current LLM call completes.
|
|
461
|
+
"""
|
|
462
|
+
|
|
463
|
+
if self._state.execution_status == ConversationExecutionStatus.PAUSED:
|
|
464
|
+
return
|
|
465
|
+
|
|
466
|
+
with self._state:
|
|
467
|
+
# Only pause when running or idle
|
|
468
|
+
if (
|
|
469
|
+
self._state.execution_status == ConversationExecutionStatus.IDLE
|
|
470
|
+
or self._state.execution_status == ConversationExecutionStatus.RUNNING
|
|
471
|
+
):
|
|
472
|
+
self._state.execution_status = ConversationExecutionStatus.PAUSED
|
|
473
|
+
pause_event = PauseEvent()
|
|
474
|
+
self._on_event(pause_event)
|
|
475
|
+
logger.info("Agent execution pause requested")
|
|
476
|
+
|
|
477
|
+
def update_secrets(self, secrets: Mapping[str, SecretValue]) -> None:
|
|
478
|
+
"""Add secrets to the conversation.
|
|
479
|
+
|
|
480
|
+
Args:
|
|
481
|
+
secrets: Dictionary mapping secret keys to values or no-arg callables.
|
|
482
|
+
SecretValue = str | Callable[[], str]. Callables are invoked lazily
|
|
483
|
+
when a command references the secret key.
|
|
484
|
+
"""
|
|
485
|
+
|
|
486
|
+
secret_registry = self._state.secret_registry
|
|
487
|
+
secret_registry.update_secrets(secrets)
|
|
488
|
+
logger.info(f"Added {len(secrets)} secrets to conversation")
|
|
489
|
+
|
|
490
|
+
def set_security_analyzer(self, analyzer: SecurityAnalyzerBase | None) -> None:
|
|
491
|
+
"""Set the security analyzer for the conversation."""
|
|
492
|
+
with self._state:
|
|
493
|
+
self._state.security_analyzer = analyzer
|
|
494
|
+
|
|
495
|
+
def close(self) -> None:
|
|
496
|
+
"""Close the conversation and clean up all tool executors."""
|
|
497
|
+
# Use getattr for safety - object may be partially constructed
|
|
498
|
+
if getattr(self, "_cleanup_initiated", False):
|
|
499
|
+
return
|
|
500
|
+
self._cleanup_initiated = True
|
|
501
|
+
logger.debug("Closing conversation and cleaning up tool executors")
|
|
502
|
+
hook_processor = getattr(self, "_hook_processor", None)
|
|
503
|
+
if hook_processor is not None:
|
|
504
|
+
hook_processor.run_session_end()
|
|
505
|
+
try:
|
|
506
|
+
self._end_observability_span()
|
|
507
|
+
except AttributeError:
|
|
508
|
+
# Object may be partially constructed; span fields may be missing.
|
|
509
|
+
pass
|
|
510
|
+
try:
|
|
511
|
+
tools_map = self.agent.tools_map
|
|
512
|
+
except (AttributeError, RuntimeError):
|
|
513
|
+
# Agent not initialized or partially constructed
|
|
514
|
+
return
|
|
515
|
+
for tool in tools_map.values():
|
|
516
|
+
try:
|
|
517
|
+
executable_tool = tool.as_executable()
|
|
518
|
+
executable_tool.executor.close()
|
|
519
|
+
except NotImplementedError:
|
|
520
|
+
# Tool has no executor, skip it without erroring
|
|
521
|
+
continue
|
|
522
|
+
except Exception as e:
|
|
523
|
+
logger.warning(f"Error closing executor for tool '{tool.name}': {e}")
|
|
524
|
+
|
|
525
|
+
def ask_agent(self, question: str) -> str:
|
|
526
|
+
"""Ask the agent a simple, stateless question and get a direct LLM response.
|
|
527
|
+
|
|
528
|
+
This bypasses the normal conversation flow and does **not** modify, persist,
|
|
529
|
+
or become part of the conversation state. The request is not remembered by
|
|
530
|
+
the main agent, no events are recorded, and execution status is untouched.
|
|
531
|
+
It is also thread-safe and may be called while `conversation.run()` is
|
|
532
|
+
executing in another thread.
|
|
533
|
+
|
|
534
|
+
Args:
|
|
535
|
+
question: A simple string question to ask the agent
|
|
536
|
+
|
|
537
|
+
Returns:
|
|
538
|
+
A string response from the agent
|
|
539
|
+
"""
|
|
540
|
+
# Import here to avoid circular imports
|
|
541
|
+
from openhands.sdk.agent.utils import make_llm_completion, prepare_llm_messages
|
|
542
|
+
|
|
543
|
+
template_dir = (
|
|
544
|
+
Path(__file__).parent.parent.parent / "context" / "prompts" / "templates"
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
question_text = render_template(
|
|
548
|
+
str(template_dir), "ask_agent_template.j2", question=question
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
# Create a user message with the context-aware question
|
|
552
|
+
user_message = Message(
|
|
553
|
+
role="user",
|
|
554
|
+
content=[TextContent(text=question_text)],
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
messages = prepare_llm_messages(
|
|
558
|
+
self.state.events, additional_messages=[user_message]
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
# Get or create the specialized ask-agent LLM
|
|
562
|
+
try:
|
|
563
|
+
question_llm = self.llm_registry.get("ask-agent-llm")
|
|
564
|
+
except KeyError:
|
|
565
|
+
question_llm = self.agent.llm.model_copy(
|
|
566
|
+
update={
|
|
567
|
+
"usage_id": "ask-agent-llm",
|
|
568
|
+
},
|
|
569
|
+
deep=True,
|
|
570
|
+
)
|
|
571
|
+
self.llm_registry.add(question_llm)
|
|
572
|
+
|
|
573
|
+
# Pass agent tools so LLM can understand tool_calls in conversation history
|
|
574
|
+
response = make_llm_completion(
|
|
575
|
+
question_llm, messages, tools=list(self.agent.tools_map.values())
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
message = response.message
|
|
579
|
+
|
|
580
|
+
# Extract the text content from the LLMResponse message
|
|
581
|
+
if message.content and len(message.content) > 0:
|
|
582
|
+
# Look for the first TextContent in the response
|
|
583
|
+
for content in response.message.content:
|
|
584
|
+
if isinstance(content, TextContent):
|
|
585
|
+
return content.text
|
|
586
|
+
|
|
587
|
+
raise Exception("Failed to generate summary")
|
|
588
|
+
|
|
589
|
+
@observe(name="conversation.generate_title", ignore_inputs=["llm"])
|
|
590
|
+
def generate_title(self, llm: LLM | None = None, max_length: int = 50) -> str:
|
|
591
|
+
"""Generate a title for the conversation based on the first user message.
|
|
592
|
+
|
|
593
|
+
Args:
|
|
594
|
+
llm: Optional LLM to use for title generation. If not provided,
|
|
595
|
+
uses self.agent.llm.
|
|
596
|
+
max_length: Maximum length of the generated title.
|
|
597
|
+
|
|
598
|
+
Returns:
|
|
599
|
+
A generated title for the conversation.
|
|
600
|
+
|
|
601
|
+
Raises:
|
|
602
|
+
ValueError: If no user messages are found in the conversation.
|
|
603
|
+
"""
|
|
604
|
+
# Use provided LLM or fall back to agent's LLM
|
|
605
|
+
llm_to_use = llm or self.agent.llm
|
|
606
|
+
|
|
607
|
+
return generate_conversation_title(
|
|
608
|
+
events=self._state.events, llm=llm_to_use, max_length=max_length
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
def condense(self) -> None:
|
|
612
|
+
"""Synchronously force condense the conversation history.
|
|
613
|
+
|
|
614
|
+
If the agent is currently running, `condense()` will wait for the
|
|
615
|
+
ongoing step to finish before proceeding.
|
|
616
|
+
|
|
617
|
+
Raises ValueError if no compatible condenser exists.
|
|
618
|
+
"""
|
|
619
|
+
|
|
620
|
+
# Check if condenser is configured and handles condensation requests
|
|
621
|
+
if (
|
|
622
|
+
self.agent.condenser is None
|
|
623
|
+
or not self.agent.condenser.handles_condensation_requests()
|
|
624
|
+
):
|
|
625
|
+
condenser_info = (
|
|
626
|
+
"No condenser configured"
|
|
627
|
+
if self.agent.condenser is None
|
|
628
|
+
else (
|
|
629
|
+
f"Condenser {type(self.agent.condenser).__name__} does not handle "
|
|
630
|
+
"condensation requests"
|
|
631
|
+
)
|
|
632
|
+
)
|
|
633
|
+
raise ValueError(
|
|
634
|
+
f"Cannot condense conversation: {condenser_info}. "
|
|
635
|
+
"To enable manual condensation, configure an "
|
|
636
|
+
"LLMSummarizingCondenser:\n\n"
|
|
637
|
+
"from openhands.sdk.context.condenser import LLMSummarizingCondenser\n"
|
|
638
|
+
"agent = Agent(\n"
|
|
639
|
+
" llm=your_llm,\n"
|
|
640
|
+
" condenser=LLMSummarizingCondenser(\n"
|
|
641
|
+
" llm=your_llm,\n"
|
|
642
|
+
" max_size=120,\n"
|
|
643
|
+
" keep_first=4\n"
|
|
644
|
+
" )\n"
|
|
645
|
+
")"
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
# Add a condensation request event
|
|
649
|
+
condensation_request = CondensationRequest()
|
|
650
|
+
self._on_event(condensation_request)
|
|
651
|
+
|
|
652
|
+
# Force the agent to take a single step to process the condensation request
|
|
653
|
+
# This will trigger the condenser if it handles condensation requests
|
|
654
|
+
with self._state:
|
|
655
|
+
# Take a single step to process the condensation request
|
|
656
|
+
self.agent.step(self, on_event=self._on_event, on_token=self._on_token)
|
|
657
|
+
|
|
658
|
+
logger.info("Condensation request processed")
|
|
659
|
+
|
|
660
|
+
def __del__(self) -> None:
|
|
661
|
+
"""Ensure cleanup happens when conversation is destroyed."""
|
|
662
|
+
try:
|
|
663
|
+
self.close()
|
|
664
|
+
except Exception as e:
|
|
665
|
+
logger.warning(f"Error during conversation cleanup: {e}", exc_info=True)
|