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,956 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from typing import SupportsIndex, overload
|
|
9
|
+
from urllib.parse import urlparse
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
import websockets
|
|
13
|
+
|
|
14
|
+
from openhands.sdk.agent.base import AgentBase
|
|
15
|
+
from openhands.sdk.conversation.base import BaseConversation, ConversationStateProtocol
|
|
16
|
+
from openhands.sdk.conversation.conversation_stats import ConversationStats
|
|
17
|
+
from openhands.sdk.conversation.events_list_base import EventsListBase
|
|
18
|
+
from openhands.sdk.conversation.exceptions import ConversationRunError
|
|
19
|
+
from openhands.sdk.conversation.secret_registry import SecretValue
|
|
20
|
+
from openhands.sdk.conversation.state import ConversationExecutionStatus
|
|
21
|
+
from openhands.sdk.conversation.types import (
|
|
22
|
+
ConversationCallbackType,
|
|
23
|
+
ConversationID,
|
|
24
|
+
StuckDetectionThresholds,
|
|
25
|
+
)
|
|
26
|
+
from openhands.sdk.conversation.visualizer import (
|
|
27
|
+
ConversationVisualizerBase,
|
|
28
|
+
DefaultConversationVisualizer,
|
|
29
|
+
)
|
|
30
|
+
from openhands.sdk.event.base import Event
|
|
31
|
+
from openhands.sdk.event.conversation_error import ConversationErrorEvent
|
|
32
|
+
from openhands.sdk.event.conversation_state import (
|
|
33
|
+
FULL_STATE_KEY,
|
|
34
|
+
ConversationStateUpdateEvent,
|
|
35
|
+
)
|
|
36
|
+
from openhands.sdk.event.llm_completion_log import LLMCompletionLogEvent
|
|
37
|
+
from openhands.sdk.hooks import (
|
|
38
|
+
HookConfig,
|
|
39
|
+
HookEventProcessor,
|
|
40
|
+
HookEventType,
|
|
41
|
+
HookManager,
|
|
42
|
+
)
|
|
43
|
+
from openhands.sdk.llm import LLM, Message, TextContent
|
|
44
|
+
from openhands.sdk.logger import DEBUG, get_logger
|
|
45
|
+
from openhands.sdk.observability.laminar import observe
|
|
46
|
+
from openhands.sdk.security.analyzer import SecurityAnalyzerBase
|
|
47
|
+
from openhands.sdk.security.confirmation_policy import (
|
|
48
|
+
ConfirmationPolicyBase,
|
|
49
|
+
)
|
|
50
|
+
from openhands.sdk.workspace import LocalWorkspace, RemoteWorkspace
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
logger = get_logger(__name__)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _send_request(
|
|
57
|
+
client: httpx.Client,
|
|
58
|
+
method: str,
|
|
59
|
+
url: str,
|
|
60
|
+
acceptable_status_codes: set[int] | None = None,
|
|
61
|
+
**kwargs,
|
|
62
|
+
) -> httpx.Response:
|
|
63
|
+
try:
|
|
64
|
+
response = client.request(method, url, **kwargs)
|
|
65
|
+
if acceptable_status_codes and response.status_code in acceptable_status_codes:
|
|
66
|
+
return response
|
|
67
|
+
response.raise_for_status()
|
|
68
|
+
return response
|
|
69
|
+
except httpx.HTTPStatusError as e:
|
|
70
|
+
content = None
|
|
71
|
+
try:
|
|
72
|
+
content = e.response.json()
|
|
73
|
+
except Exception:
|
|
74
|
+
content = e.response.text
|
|
75
|
+
logger.error(
|
|
76
|
+
"HTTP request failed (%d %s): %s",
|
|
77
|
+
e.response.status_code,
|
|
78
|
+
e.response.reason_phrase,
|
|
79
|
+
content,
|
|
80
|
+
exc_info=True,
|
|
81
|
+
)
|
|
82
|
+
raise e
|
|
83
|
+
except httpx.RequestError as e:
|
|
84
|
+
logger.error(f"Request failed: {e}", exc_info=DEBUG)
|
|
85
|
+
raise e
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class WebSocketCallbackClient:
|
|
89
|
+
"""Minimal WS client: connects, forwards events, retries on error."""
|
|
90
|
+
|
|
91
|
+
host: str
|
|
92
|
+
conversation_id: str
|
|
93
|
+
callback: ConversationCallbackType
|
|
94
|
+
api_key: str | None
|
|
95
|
+
_thread: threading.Thread | None
|
|
96
|
+
_stop: threading.Event
|
|
97
|
+
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
host: str,
|
|
101
|
+
conversation_id: str,
|
|
102
|
+
callback: ConversationCallbackType,
|
|
103
|
+
api_key: str | None = None,
|
|
104
|
+
):
|
|
105
|
+
self.host = host
|
|
106
|
+
self.conversation_id = conversation_id
|
|
107
|
+
self.callback = callback
|
|
108
|
+
self.api_key = api_key
|
|
109
|
+
self._thread = None
|
|
110
|
+
self._stop = threading.Event()
|
|
111
|
+
|
|
112
|
+
def start(self) -> None:
|
|
113
|
+
if self._thread:
|
|
114
|
+
return
|
|
115
|
+
self._stop.clear()
|
|
116
|
+
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
117
|
+
self._thread.start()
|
|
118
|
+
|
|
119
|
+
def stop(self) -> None:
|
|
120
|
+
if not self._thread:
|
|
121
|
+
return
|
|
122
|
+
self._stop.set()
|
|
123
|
+
self._thread.join(timeout=5)
|
|
124
|
+
self._thread = None
|
|
125
|
+
|
|
126
|
+
def _run(self) -> None:
|
|
127
|
+
try:
|
|
128
|
+
asyncio.run(self._client_loop())
|
|
129
|
+
except RuntimeError:
|
|
130
|
+
# Fallback in case of an already running loop in rare environments
|
|
131
|
+
loop = asyncio.new_event_loop()
|
|
132
|
+
asyncio.set_event_loop(loop)
|
|
133
|
+
loop.run_until_complete(self._client_loop())
|
|
134
|
+
loop.close()
|
|
135
|
+
|
|
136
|
+
async def _client_loop(self) -> None:
|
|
137
|
+
parsed = urlparse(self.host)
|
|
138
|
+
ws_scheme = "wss" if parsed.scheme == "https" else "ws"
|
|
139
|
+
base = f"{ws_scheme}://{parsed.netloc}{parsed.path.rstrip('/')}"
|
|
140
|
+
ws_url = f"{base}/sockets/events/{self.conversation_id}"
|
|
141
|
+
|
|
142
|
+
# Add API key as query parameter if provided
|
|
143
|
+
if self.api_key:
|
|
144
|
+
ws_url += f"?session_api_key={self.api_key}"
|
|
145
|
+
|
|
146
|
+
delay = 1.0
|
|
147
|
+
while not self._stop.is_set():
|
|
148
|
+
try:
|
|
149
|
+
async with websockets.connect(ws_url) as ws:
|
|
150
|
+
delay = 1.0
|
|
151
|
+
async for message in ws:
|
|
152
|
+
if self._stop.is_set():
|
|
153
|
+
break
|
|
154
|
+
try:
|
|
155
|
+
event = Event.model_validate(json.loads(message))
|
|
156
|
+
self.callback(event)
|
|
157
|
+
except Exception:
|
|
158
|
+
logger.exception(
|
|
159
|
+
"ws_event_processing_error", stack_info=True
|
|
160
|
+
)
|
|
161
|
+
except websockets.exceptions.ConnectionClosed:
|
|
162
|
+
break
|
|
163
|
+
except Exception:
|
|
164
|
+
logger.debug("ws_connect_retry", exc_info=True)
|
|
165
|
+
await asyncio.sleep(delay)
|
|
166
|
+
delay = min(delay * 2, 30.0)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class RemoteEventsList(EventsListBase):
|
|
170
|
+
"""A list-like, read-only view of remote conversation events.
|
|
171
|
+
|
|
172
|
+
On first access it fetches existing events from the server. Afterwards,
|
|
173
|
+
it relies on the WebSocket stream to incrementally append new events.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
_client: httpx.Client
|
|
177
|
+
_conversation_id: str
|
|
178
|
+
_cached_events: list[Event]
|
|
179
|
+
_cached_event_ids: set[str]
|
|
180
|
+
_lock: threading.RLock
|
|
181
|
+
|
|
182
|
+
def __init__(self, client: httpx.Client, conversation_id: str):
|
|
183
|
+
self._client = client
|
|
184
|
+
self._conversation_id = conversation_id
|
|
185
|
+
self._cached_events: list[Event] = []
|
|
186
|
+
self._cached_event_ids: set[str] = set()
|
|
187
|
+
self._lock = threading.RLock()
|
|
188
|
+
# Initial fetch to sync existing events
|
|
189
|
+
self._do_full_sync()
|
|
190
|
+
|
|
191
|
+
def _do_full_sync(self) -> None:
|
|
192
|
+
"""Perform a full sync with the remote API."""
|
|
193
|
+
logger.debug(f"Performing full sync for conversation {self._conversation_id}")
|
|
194
|
+
|
|
195
|
+
events = []
|
|
196
|
+
page_id = None
|
|
197
|
+
|
|
198
|
+
while True:
|
|
199
|
+
params = {"limit": 100}
|
|
200
|
+
if page_id:
|
|
201
|
+
params["page_id"] = page_id
|
|
202
|
+
|
|
203
|
+
resp = _send_request(
|
|
204
|
+
self._client,
|
|
205
|
+
"GET",
|
|
206
|
+
f"/api/conversations/{self._conversation_id}/events/search",
|
|
207
|
+
params=params,
|
|
208
|
+
)
|
|
209
|
+
data = resp.json()
|
|
210
|
+
|
|
211
|
+
events.extend([Event.model_validate(item) for item in data["items"]])
|
|
212
|
+
|
|
213
|
+
if not data.get("next_page_id"):
|
|
214
|
+
break
|
|
215
|
+
page_id = data["next_page_id"]
|
|
216
|
+
|
|
217
|
+
self._cached_events = events
|
|
218
|
+
self._cached_event_ids.update(e.id for e in events)
|
|
219
|
+
logger.debug(f"Full sync completed, {len(events)} events cached")
|
|
220
|
+
|
|
221
|
+
def add_event(self, event: Event) -> None:
|
|
222
|
+
"""Add a new event to the local cache (called by WebSocket callback)."""
|
|
223
|
+
with self._lock:
|
|
224
|
+
# Check if event already exists to avoid duplicates
|
|
225
|
+
if event.id not in self._cached_event_ids:
|
|
226
|
+
self._cached_events.append(event)
|
|
227
|
+
self._cached_event_ids.add(event.id)
|
|
228
|
+
logger.debug(f"Added event {event.id} to local cache")
|
|
229
|
+
|
|
230
|
+
def append(self, event: Event) -> None:
|
|
231
|
+
"""Add a new event to the list (for compatibility with EventLog interface)."""
|
|
232
|
+
self.add_event(event)
|
|
233
|
+
|
|
234
|
+
def create_default_callback(self) -> ConversationCallbackType:
|
|
235
|
+
"""Create a default callback that adds events to this list."""
|
|
236
|
+
|
|
237
|
+
def callback(event: Event) -> None:
|
|
238
|
+
self.add_event(event)
|
|
239
|
+
|
|
240
|
+
return callback
|
|
241
|
+
|
|
242
|
+
def __len__(self) -> int:
|
|
243
|
+
return len(self._cached_events)
|
|
244
|
+
|
|
245
|
+
@overload
|
|
246
|
+
def __getitem__(self, index: int) -> Event: ...
|
|
247
|
+
|
|
248
|
+
@overload
|
|
249
|
+
def __getitem__(self, index: slice) -> list[Event]: ...
|
|
250
|
+
|
|
251
|
+
def __getitem__(self, index: SupportsIndex | slice) -> Event | list[Event]:
|
|
252
|
+
with self._lock:
|
|
253
|
+
return self._cached_events[index]
|
|
254
|
+
|
|
255
|
+
def __iter__(self):
|
|
256
|
+
with self._lock:
|
|
257
|
+
return iter(self._cached_events)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class RemoteState(ConversationStateProtocol):
|
|
261
|
+
"""A state-like interface for accessing remote conversation state."""
|
|
262
|
+
|
|
263
|
+
_client: httpx.Client
|
|
264
|
+
_conversation_id: str
|
|
265
|
+
_events: RemoteEventsList
|
|
266
|
+
_cached_state: dict | None
|
|
267
|
+
_lock: threading.RLock
|
|
268
|
+
|
|
269
|
+
def __init__(self, client: httpx.Client, conversation_id: str):
|
|
270
|
+
self._client = client
|
|
271
|
+
self._conversation_id = conversation_id
|
|
272
|
+
self._events = RemoteEventsList(client, conversation_id)
|
|
273
|
+
|
|
274
|
+
# Cache for state information to avoid REST calls
|
|
275
|
+
self._cached_state = None
|
|
276
|
+
self._lock = threading.RLock()
|
|
277
|
+
|
|
278
|
+
def _get_conversation_info(self) -> dict:
|
|
279
|
+
"""Fetch the latest conversation info from the remote API."""
|
|
280
|
+
with self._lock:
|
|
281
|
+
# Return cached state if available
|
|
282
|
+
if self._cached_state is not None:
|
|
283
|
+
return self._cached_state
|
|
284
|
+
|
|
285
|
+
# Fallback to REST API if no cached state
|
|
286
|
+
resp = _send_request(
|
|
287
|
+
self._client, "GET", f"/api/conversations/{self._conversation_id}"
|
|
288
|
+
)
|
|
289
|
+
state = resp.json()
|
|
290
|
+
self._cached_state = state
|
|
291
|
+
return state
|
|
292
|
+
|
|
293
|
+
def update_state_from_event(self, event: ConversationStateUpdateEvent) -> None:
|
|
294
|
+
"""Update cached state from a ConversationStateUpdateEvent."""
|
|
295
|
+
with self._lock:
|
|
296
|
+
# Handle full state snapshot
|
|
297
|
+
if event.key == FULL_STATE_KEY:
|
|
298
|
+
# Update cached state with the full snapshot
|
|
299
|
+
if self._cached_state is None:
|
|
300
|
+
self._cached_state = {}
|
|
301
|
+
self._cached_state.update(event.value)
|
|
302
|
+
else:
|
|
303
|
+
# Handle individual field updates
|
|
304
|
+
if self._cached_state is None:
|
|
305
|
+
self._cached_state = {}
|
|
306
|
+
self._cached_state[event.key] = event.value
|
|
307
|
+
|
|
308
|
+
def create_state_update_callback(self) -> ConversationCallbackType:
|
|
309
|
+
"""Create a callback that updates state from ConversationStateUpdateEvent."""
|
|
310
|
+
|
|
311
|
+
def callback(event: Event) -> None:
|
|
312
|
+
if isinstance(event, ConversationStateUpdateEvent):
|
|
313
|
+
self.update_state_from_event(event)
|
|
314
|
+
|
|
315
|
+
return callback
|
|
316
|
+
|
|
317
|
+
@property
|
|
318
|
+
def events(self) -> RemoteEventsList:
|
|
319
|
+
"""Access to the events list."""
|
|
320
|
+
return self._events
|
|
321
|
+
|
|
322
|
+
@property
|
|
323
|
+
def id(self) -> ConversationID:
|
|
324
|
+
"""The conversation ID."""
|
|
325
|
+
return uuid.UUID(self._conversation_id)
|
|
326
|
+
|
|
327
|
+
@property
|
|
328
|
+
def execution_status(self) -> ConversationExecutionStatus:
|
|
329
|
+
"""The current conversation execution status."""
|
|
330
|
+
info = self._get_conversation_info()
|
|
331
|
+
status_str = info.get("execution_status")
|
|
332
|
+
if status_str is None:
|
|
333
|
+
raise RuntimeError(
|
|
334
|
+
"execution_status missing in conversation info: " + str(info)
|
|
335
|
+
)
|
|
336
|
+
return ConversationExecutionStatus(status_str)
|
|
337
|
+
|
|
338
|
+
@execution_status.setter
|
|
339
|
+
def execution_status(self, value: ConversationExecutionStatus) -> None:
|
|
340
|
+
"""Set execution status is No-OP for RemoteConversation.
|
|
341
|
+
|
|
342
|
+
# For remote conversations, execution status is managed server-side
|
|
343
|
+
# This setter is provided for test compatibility but doesn't actually change remote state # noqa: E501
|
|
344
|
+
""" # noqa: E501
|
|
345
|
+
raise NotImplementedError(
|
|
346
|
+
f"Setting execution_status on RemoteState has no effect. "
|
|
347
|
+
f"Remote execution status is managed server-side. Attempted to set: {value}"
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
@property
|
|
351
|
+
def confirmation_policy(self) -> ConfirmationPolicyBase:
|
|
352
|
+
"""The confirmation policy."""
|
|
353
|
+
info = self._get_conversation_info()
|
|
354
|
+
policy_data = info.get("confirmation_policy")
|
|
355
|
+
if policy_data is None:
|
|
356
|
+
raise RuntimeError(
|
|
357
|
+
"confirmation_policy missing in conversation info: " + str(info)
|
|
358
|
+
)
|
|
359
|
+
return ConfirmationPolicyBase.model_validate(policy_data)
|
|
360
|
+
|
|
361
|
+
@property
|
|
362
|
+
def security_analyzer(self) -> SecurityAnalyzerBase | None:
|
|
363
|
+
"""The security analyzer."""
|
|
364
|
+
info = self._get_conversation_info()
|
|
365
|
+
analyzer_data = info.get("security_analyzer")
|
|
366
|
+
if analyzer_data:
|
|
367
|
+
return SecurityAnalyzerBase.model_validate(analyzer_data)
|
|
368
|
+
|
|
369
|
+
return None
|
|
370
|
+
|
|
371
|
+
@property
|
|
372
|
+
def activated_knowledge_skills(self) -> list[str]:
|
|
373
|
+
"""List of activated knowledge skills."""
|
|
374
|
+
info = self._get_conversation_info()
|
|
375
|
+
return info.get("activated_knowledge_skills", [])
|
|
376
|
+
|
|
377
|
+
@property
|
|
378
|
+
def agent(self):
|
|
379
|
+
"""The agent configuration (fetched from remote)."""
|
|
380
|
+
info = self._get_conversation_info()
|
|
381
|
+
agent_data = info.get("agent")
|
|
382
|
+
if agent_data is None:
|
|
383
|
+
raise RuntimeError("agent missing in conversation info: " + str(info))
|
|
384
|
+
return AgentBase.model_validate(agent_data)
|
|
385
|
+
|
|
386
|
+
@property
|
|
387
|
+
def workspace(self):
|
|
388
|
+
"""The working directory (fetched from remote)."""
|
|
389
|
+
info = self._get_conversation_info()
|
|
390
|
+
workspace = info.get("workspace")
|
|
391
|
+
if workspace is None:
|
|
392
|
+
raise RuntimeError("workspace missing in conversation info: " + str(info))
|
|
393
|
+
return workspace
|
|
394
|
+
|
|
395
|
+
@property
|
|
396
|
+
def persistence_dir(self):
|
|
397
|
+
"""The persistence directory (fetched from remote)."""
|
|
398
|
+
info = self._get_conversation_info()
|
|
399
|
+
persistence_dir = info.get("persistence_dir")
|
|
400
|
+
if persistence_dir is None:
|
|
401
|
+
raise RuntimeError(
|
|
402
|
+
"persistence_dir missing in conversation info: " + str(info)
|
|
403
|
+
)
|
|
404
|
+
return persistence_dir
|
|
405
|
+
|
|
406
|
+
@property
|
|
407
|
+
def stats(self) -> ConversationStats:
|
|
408
|
+
"""Get conversation stats (fetched from remote)."""
|
|
409
|
+
info = self._get_conversation_info()
|
|
410
|
+
stats_data = info.get("stats", {})
|
|
411
|
+
return ConversationStats.model_validate(stats_data)
|
|
412
|
+
|
|
413
|
+
def model_dump(self, **_kwargs):
|
|
414
|
+
"""Get a dictionary representation of the remote state."""
|
|
415
|
+
info = self._get_conversation_info()
|
|
416
|
+
return info
|
|
417
|
+
|
|
418
|
+
def model_dump_json(self, **kwargs):
|
|
419
|
+
"""Get a JSON representation of the remote state."""
|
|
420
|
+
return json.dumps(self.model_dump(**kwargs))
|
|
421
|
+
|
|
422
|
+
# Context manager methods for compatibility with ConversationState
|
|
423
|
+
def __enter__(self):
|
|
424
|
+
return self
|
|
425
|
+
|
|
426
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
427
|
+
pass
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
class RemoteConversation(BaseConversation):
|
|
431
|
+
_id: uuid.UUID
|
|
432
|
+
_state: "RemoteState"
|
|
433
|
+
_visualizer: ConversationVisualizerBase | None
|
|
434
|
+
_ws_client: "WebSocketCallbackClient | None"
|
|
435
|
+
agent: AgentBase
|
|
436
|
+
_callbacks: list[ConversationCallbackType]
|
|
437
|
+
max_iteration_per_run: int
|
|
438
|
+
workspace: RemoteWorkspace
|
|
439
|
+
_client: httpx.Client
|
|
440
|
+
_hook_processor: HookEventProcessor | None
|
|
441
|
+
_cleanup_initiated: bool
|
|
442
|
+
|
|
443
|
+
def __init__(
|
|
444
|
+
self,
|
|
445
|
+
agent: AgentBase,
|
|
446
|
+
workspace: RemoteWorkspace,
|
|
447
|
+
conversation_id: ConversationID | None = None,
|
|
448
|
+
callbacks: list[ConversationCallbackType] | None = None,
|
|
449
|
+
max_iteration_per_run: int = 500,
|
|
450
|
+
stuck_detection: bool = True,
|
|
451
|
+
stuck_detection_thresholds: (
|
|
452
|
+
StuckDetectionThresholds | Mapping[str, int] | None
|
|
453
|
+
) = None,
|
|
454
|
+
hook_config: HookConfig | None = None,
|
|
455
|
+
visualizer: (
|
|
456
|
+
type[ConversationVisualizerBase] | ConversationVisualizerBase | None
|
|
457
|
+
) = DefaultConversationVisualizer,
|
|
458
|
+
secrets: Mapping[str, SecretValue] | None = None,
|
|
459
|
+
**_: object,
|
|
460
|
+
) -> None:
|
|
461
|
+
"""Remote conversation proxy that talks to an agent server.
|
|
462
|
+
|
|
463
|
+
Args:
|
|
464
|
+
agent: Agent configuration (will be sent to the server)
|
|
465
|
+
workspace: The working directory for agent operations and tool execution.
|
|
466
|
+
conversation_id: Optional existing conversation id to attach to
|
|
467
|
+
callbacks: Optional callbacks to receive events (not yet streamed)
|
|
468
|
+
max_iteration_per_run: Max iterations configured on server
|
|
469
|
+
stuck_detection: Whether to enable stuck detection on server
|
|
470
|
+
stuck_detection_thresholds: Optional configuration for stuck detection
|
|
471
|
+
thresholds. Can be a StuckDetectionThresholds instance or
|
|
472
|
+
a dict with keys: 'action_observation', 'action_error',
|
|
473
|
+
'monologue', 'alternating_pattern'. Values are integers
|
|
474
|
+
representing the number of repetitions before triggering.
|
|
475
|
+
hook_config: Optional hook configuration for session hooks
|
|
476
|
+
visualizer: Visualization configuration. Can be:
|
|
477
|
+
- ConversationVisualizerBase subclass: Class to instantiate
|
|
478
|
+
(default: ConversationVisualizer)
|
|
479
|
+
- ConversationVisualizerBase instance: Use custom visualizer
|
|
480
|
+
- None: No visualization
|
|
481
|
+
secrets: Optional secrets to initialize the conversation with
|
|
482
|
+
"""
|
|
483
|
+
super().__init__() # Initialize base class with span tracking
|
|
484
|
+
self.agent = agent
|
|
485
|
+
self._callbacks = callbacks or []
|
|
486
|
+
self.max_iteration_per_run = max_iteration_per_run
|
|
487
|
+
self.workspace = workspace
|
|
488
|
+
self._client = workspace.client
|
|
489
|
+
self._hook_processor = None
|
|
490
|
+
self._cleanup_initiated = False
|
|
491
|
+
|
|
492
|
+
if conversation_id is None:
|
|
493
|
+
# Import here to avoid circular imports
|
|
494
|
+
from openhands.sdk.tool.registry import get_tool_module_qualnames
|
|
495
|
+
|
|
496
|
+
tool_qualnames = get_tool_module_qualnames()
|
|
497
|
+
logger.debug(f"Sending tool_module_qualnames to server: {tool_qualnames}")
|
|
498
|
+
payload = {
|
|
499
|
+
"agent": agent.model_dump(
|
|
500
|
+
mode="json", context={"expose_secrets": True}
|
|
501
|
+
),
|
|
502
|
+
"initial_message": None,
|
|
503
|
+
"max_iterations": max_iteration_per_run,
|
|
504
|
+
"stuck_detection": stuck_detection,
|
|
505
|
+
# We need to convert RemoteWorkspace to LocalWorkspace for the server
|
|
506
|
+
"workspace": LocalWorkspace(
|
|
507
|
+
working_dir=self.workspace.working_dir
|
|
508
|
+
).model_dump(),
|
|
509
|
+
# Include tool module qualnames for dynamic registration on server
|
|
510
|
+
"tool_module_qualnames": tool_qualnames,
|
|
511
|
+
}
|
|
512
|
+
if stuck_detection_thresholds is not None:
|
|
513
|
+
# Convert to StuckDetectionThresholds if dict, then serialize
|
|
514
|
+
if isinstance(stuck_detection_thresholds, Mapping):
|
|
515
|
+
threshold_config = StuckDetectionThresholds(
|
|
516
|
+
**stuck_detection_thresholds
|
|
517
|
+
)
|
|
518
|
+
else:
|
|
519
|
+
threshold_config = stuck_detection_thresholds
|
|
520
|
+
payload["stuck_detection_thresholds"] = threshold_config.model_dump()
|
|
521
|
+
resp = _send_request(
|
|
522
|
+
self._client, "POST", "/api/conversations", json=payload
|
|
523
|
+
)
|
|
524
|
+
data = resp.json()
|
|
525
|
+
# Expect a ConversationInfo
|
|
526
|
+
cid = data.get("id") or data.get("conversation_id")
|
|
527
|
+
if not cid:
|
|
528
|
+
raise RuntimeError(
|
|
529
|
+
"Invalid response from server: missing conversation id"
|
|
530
|
+
)
|
|
531
|
+
self._id = uuid.UUID(cid)
|
|
532
|
+
else:
|
|
533
|
+
# Attach to existing
|
|
534
|
+
self._id = conversation_id
|
|
535
|
+
# Validate it exists
|
|
536
|
+
_send_request(self._client, "GET", f"/api/conversations/{self._id}")
|
|
537
|
+
|
|
538
|
+
# Initialize the remote state
|
|
539
|
+
self._state = RemoteState(self._client, str(self._id))
|
|
540
|
+
|
|
541
|
+
# Add default callback to maintain local event state
|
|
542
|
+
default_callback = self._state.events.create_default_callback()
|
|
543
|
+
self._callbacks.append(default_callback)
|
|
544
|
+
|
|
545
|
+
# Add callback to update state from websocket events
|
|
546
|
+
state_update_callback = self._state.create_state_update_callback()
|
|
547
|
+
self._callbacks.append(state_update_callback)
|
|
548
|
+
|
|
549
|
+
# Add callback to handle LLM completion logs
|
|
550
|
+
# Register callback if any LLM has log_completions enabled
|
|
551
|
+
if any(llm.log_completions for llm in agent.get_all_llms()):
|
|
552
|
+
llm_log_callback = self._create_llm_completion_log_callback()
|
|
553
|
+
self._callbacks.append(llm_log_callback)
|
|
554
|
+
|
|
555
|
+
# Handle visualization configuration
|
|
556
|
+
if isinstance(visualizer, ConversationVisualizerBase):
|
|
557
|
+
# Use custom visualizer instance
|
|
558
|
+
self._visualizer = visualizer
|
|
559
|
+
# Initialize the visualizer with conversation state
|
|
560
|
+
self._visualizer.initialize(self._state)
|
|
561
|
+
self._callbacks.append(self._visualizer.on_event)
|
|
562
|
+
elif isinstance(visualizer, type) and issubclass(
|
|
563
|
+
visualizer, ConversationVisualizerBase
|
|
564
|
+
):
|
|
565
|
+
# Instantiate the visualizer class with appropriate parameters
|
|
566
|
+
self._visualizer = visualizer()
|
|
567
|
+
# Initialize with state
|
|
568
|
+
self._visualizer.initialize(self._state)
|
|
569
|
+
self._callbacks.append(self._visualizer.on_event)
|
|
570
|
+
else:
|
|
571
|
+
# No visualization (visualizer is None)
|
|
572
|
+
self._visualizer = None
|
|
573
|
+
|
|
574
|
+
# Compose all callbacks into a single callback
|
|
575
|
+
composed_callback = BaseConversation.compose_callbacks(self._callbacks)
|
|
576
|
+
|
|
577
|
+
# Initialize WebSocket client for callbacks
|
|
578
|
+
self._ws_client = WebSocketCallbackClient(
|
|
579
|
+
host=self.workspace.host,
|
|
580
|
+
conversation_id=str(self._id),
|
|
581
|
+
callback=composed_callback,
|
|
582
|
+
api_key=self.workspace.api_key,
|
|
583
|
+
)
|
|
584
|
+
self._ws_client.start()
|
|
585
|
+
|
|
586
|
+
# Initialize secrets if provided
|
|
587
|
+
if secrets:
|
|
588
|
+
# Convert dict[str, str] to dict[str, SecretValue]
|
|
589
|
+
secret_values: dict[str, SecretValue] = {k: v for k, v in secrets.items()}
|
|
590
|
+
self.update_secrets(secret_values)
|
|
591
|
+
|
|
592
|
+
self._start_observability_span(str(self._id))
|
|
593
|
+
if hook_config is not None:
|
|
594
|
+
unsupported = (
|
|
595
|
+
HookEventType.PRE_TOOL_USE,
|
|
596
|
+
HookEventType.POST_TOOL_USE,
|
|
597
|
+
HookEventType.USER_PROMPT_SUBMIT,
|
|
598
|
+
HookEventType.STOP,
|
|
599
|
+
)
|
|
600
|
+
if any(hook_config.has_hooks_for_event(t) for t in unsupported):
|
|
601
|
+
logger.warning(
|
|
602
|
+
"RemoteConversation only supports SessionStart/SessionEnd hooks; "
|
|
603
|
+
"other hook types will not be enforced."
|
|
604
|
+
)
|
|
605
|
+
hook_manager = HookManager(
|
|
606
|
+
config=hook_config,
|
|
607
|
+
working_dir=os.getcwd(),
|
|
608
|
+
session_id=str(self._id),
|
|
609
|
+
)
|
|
610
|
+
self._hook_processor = HookEventProcessor(hook_manager=hook_manager)
|
|
611
|
+
self._hook_processor.run_session_start()
|
|
612
|
+
|
|
613
|
+
def _create_llm_completion_log_callback(self) -> ConversationCallbackType:
|
|
614
|
+
"""Create a callback that writes LLM completion logs to client filesystem."""
|
|
615
|
+
|
|
616
|
+
def callback(event: Event) -> None:
|
|
617
|
+
if not isinstance(event, LLMCompletionLogEvent):
|
|
618
|
+
return
|
|
619
|
+
|
|
620
|
+
# Find the LLM with matching usage_id
|
|
621
|
+
target_llm = None
|
|
622
|
+
for llm in self.agent.get_all_llms():
|
|
623
|
+
if llm.usage_id == event.usage_id:
|
|
624
|
+
target_llm = llm
|
|
625
|
+
break
|
|
626
|
+
|
|
627
|
+
if not target_llm or not target_llm.log_completions:
|
|
628
|
+
logger.debug(
|
|
629
|
+
f"No LLM with log_completions enabled found "
|
|
630
|
+
f"for usage_id={event.usage_id}"
|
|
631
|
+
)
|
|
632
|
+
return
|
|
633
|
+
|
|
634
|
+
try:
|
|
635
|
+
log_dir = target_llm.log_completions_folder
|
|
636
|
+
os.makedirs(log_dir, exist_ok=True)
|
|
637
|
+
log_path = os.path.join(log_dir, event.filename)
|
|
638
|
+
with open(log_path, "w") as f:
|
|
639
|
+
f.write(event.log_data)
|
|
640
|
+
logger.debug(f"Wrote LLM completion log to {log_path}")
|
|
641
|
+
except Exception as e:
|
|
642
|
+
logger.warning(f"Failed to write LLM completion log: {e}")
|
|
643
|
+
|
|
644
|
+
return callback
|
|
645
|
+
|
|
646
|
+
@property
|
|
647
|
+
def id(self) -> ConversationID:
|
|
648
|
+
return self._id
|
|
649
|
+
|
|
650
|
+
@property
|
|
651
|
+
def state(self) -> RemoteState:
|
|
652
|
+
"""Access to remote conversation state."""
|
|
653
|
+
return self._state
|
|
654
|
+
|
|
655
|
+
@property
|
|
656
|
+
def conversation_stats(self):
|
|
657
|
+
return self._state.stats
|
|
658
|
+
|
|
659
|
+
@property
|
|
660
|
+
def stuck_detector(self):
|
|
661
|
+
"""Stuck detector for compatibility.
|
|
662
|
+
Not implemented for remote conversations."""
|
|
663
|
+
raise NotImplementedError(
|
|
664
|
+
"For remote conversations, stuck detection is not available"
|
|
665
|
+
" since it would be handled server-side."
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
@observe(name="conversation.send_message")
|
|
669
|
+
def send_message(self, message: str | Message, sender: str | None = None) -> None:
|
|
670
|
+
if isinstance(message, str):
|
|
671
|
+
message = Message(role="user", content=[TextContent(text=message)])
|
|
672
|
+
assert message.role == "user", (
|
|
673
|
+
"Only user messages are allowed to be sent to the agent."
|
|
674
|
+
)
|
|
675
|
+
payload = {
|
|
676
|
+
"role": message.role,
|
|
677
|
+
"content": [c.model_dump() for c in message.content],
|
|
678
|
+
"run": False, # Mirror local semantics; explicit run() must be called
|
|
679
|
+
}
|
|
680
|
+
if sender is not None:
|
|
681
|
+
payload["sender"] = sender
|
|
682
|
+
_send_request(
|
|
683
|
+
self._client, "POST", f"/api/conversations/{self._id}/events", json=payload
|
|
684
|
+
)
|
|
685
|
+
|
|
686
|
+
@observe(name="conversation.run")
|
|
687
|
+
def run(
|
|
688
|
+
self,
|
|
689
|
+
blocking: bool = True,
|
|
690
|
+
poll_interval: float = 1.0,
|
|
691
|
+
timeout: float = 3600.0,
|
|
692
|
+
) -> None:
|
|
693
|
+
"""Trigger a run on the server.
|
|
694
|
+
|
|
695
|
+
Args:
|
|
696
|
+
blocking: If True (default), wait for the run to complete by polling
|
|
697
|
+
the server. If False, return immediately after triggering the run.
|
|
698
|
+
poll_interval: Time in seconds between status polls (only used when
|
|
699
|
+
blocking=True). Default is 1.0 second.
|
|
700
|
+
timeout: Maximum time in seconds to wait for the run to complete
|
|
701
|
+
(only used when blocking=True). Default is 3600 seconds.
|
|
702
|
+
|
|
703
|
+
Raises:
|
|
704
|
+
ConversationRunError: If the run fails or times out.
|
|
705
|
+
"""
|
|
706
|
+
# Trigger a run on the server using the dedicated run endpoint.
|
|
707
|
+
# Let the server tell us if it's already running (409), avoiding an extra GET.
|
|
708
|
+
try:
|
|
709
|
+
resp = _send_request(
|
|
710
|
+
self._client,
|
|
711
|
+
"POST",
|
|
712
|
+
f"/api/conversations/{self._id}/run",
|
|
713
|
+
acceptable_status_codes={200, 201, 204, 409},
|
|
714
|
+
timeout=30, # Short timeout for trigger request
|
|
715
|
+
)
|
|
716
|
+
except Exception as e: # httpx errors already logged by _send_request
|
|
717
|
+
# Surface conversation id to help resuming
|
|
718
|
+
raise ConversationRunError(self._id, e) from e
|
|
719
|
+
|
|
720
|
+
if resp.status_code == 409:
|
|
721
|
+
logger.info("Conversation is already running; skipping run trigger")
|
|
722
|
+
if blocking:
|
|
723
|
+
# Still wait for the existing run to complete
|
|
724
|
+
self._wait_for_run_completion(poll_interval, timeout)
|
|
725
|
+
return
|
|
726
|
+
|
|
727
|
+
logger.info(f"run() triggered successfully: {resp}")
|
|
728
|
+
|
|
729
|
+
if blocking:
|
|
730
|
+
self._wait_for_run_completion(poll_interval, timeout)
|
|
731
|
+
|
|
732
|
+
def _wait_for_run_completion(
|
|
733
|
+
self,
|
|
734
|
+
poll_interval: float = 1.0,
|
|
735
|
+
timeout: float = 1800.0,
|
|
736
|
+
) -> None:
|
|
737
|
+
"""Poll the server until the conversation is no longer running.
|
|
738
|
+
|
|
739
|
+
Args:
|
|
740
|
+
poll_interval: Time in seconds between status polls.
|
|
741
|
+
timeout: Maximum time in seconds to wait.
|
|
742
|
+
|
|
743
|
+
Raises:
|
|
744
|
+
ConversationRunError: If the wait times out.
|
|
745
|
+
"""
|
|
746
|
+
start_time = time.monotonic()
|
|
747
|
+
|
|
748
|
+
while True:
|
|
749
|
+
elapsed = time.monotonic() - start_time
|
|
750
|
+
if elapsed > timeout:
|
|
751
|
+
raise ConversationRunError(
|
|
752
|
+
self._id,
|
|
753
|
+
TimeoutError(
|
|
754
|
+
f"Run timed out after {timeout} seconds. "
|
|
755
|
+
"The conversation may still be running on the server."
|
|
756
|
+
),
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
try:
|
|
760
|
+
resp = _send_request(
|
|
761
|
+
self._client,
|
|
762
|
+
"GET",
|
|
763
|
+
f"/api/conversations/{self._id}",
|
|
764
|
+
timeout=30,
|
|
765
|
+
)
|
|
766
|
+
info = resp.json()
|
|
767
|
+
status = info.get("execution_status")
|
|
768
|
+
|
|
769
|
+
if status != ConversationExecutionStatus.RUNNING.value:
|
|
770
|
+
if status == ConversationExecutionStatus.ERROR.value:
|
|
771
|
+
detail = self._get_last_error_detail()
|
|
772
|
+
raise ConversationRunError(
|
|
773
|
+
self._id,
|
|
774
|
+
RuntimeError(
|
|
775
|
+
detail or "Remote conversation ended with error"
|
|
776
|
+
),
|
|
777
|
+
)
|
|
778
|
+
if status == ConversationExecutionStatus.STUCK.value:
|
|
779
|
+
raise ConversationRunError(
|
|
780
|
+
self._id,
|
|
781
|
+
RuntimeError("Remote conversation got stuck"),
|
|
782
|
+
)
|
|
783
|
+
logger.info(
|
|
784
|
+
f"Run completed with status: {status} (elapsed: {elapsed:.1f}s)"
|
|
785
|
+
)
|
|
786
|
+
return
|
|
787
|
+
|
|
788
|
+
except Exception as e:
|
|
789
|
+
# Log but continue polling - transient network errors shouldn't
|
|
790
|
+
# stop us from waiting for the run to complete
|
|
791
|
+
logger.warning(f"Error polling status (will retry): {e}")
|
|
792
|
+
|
|
793
|
+
time.sleep(poll_interval)
|
|
794
|
+
|
|
795
|
+
def _get_last_error_detail(self) -> str | None:
|
|
796
|
+
"""Return the most recent ConversationErrorEvent detail, if available."""
|
|
797
|
+
try:
|
|
798
|
+
events = self._state.events
|
|
799
|
+
for idx in range(len(events) - 1, -1, -1):
|
|
800
|
+
event = events[idx]
|
|
801
|
+
if isinstance(event, ConversationErrorEvent):
|
|
802
|
+
detail = event.detail.strip()
|
|
803
|
+
code = event.code.strip()
|
|
804
|
+
if detail and code:
|
|
805
|
+
return f"{code}: {detail}"
|
|
806
|
+
return detail or code or None
|
|
807
|
+
except Exception as exc:
|
|
808
|
+
logger.debug("Failed to read conversation error detail: %s", exc)
|
|
809
|
+
return None
|
|
810
|
+
|
|
811
|
+
def set_confirmation_policy(self, policy: ConfirmationPolicyBase) -> None:
|
|
812
|
+
payload = {"policy": policy.model_dump()}
|
|
813
|
+
_send_request(
|
|
814
|
+
self._client,
|
|
815
|
+
"POST",
|
|
816
|
+
f"/api/conversations/{self._id}/confirmation_policy",
|
|
817
|
+
json=payload,
|
|
818
|
+
)
|
|
819
|
+
|
|
820
|
+
def set_security_analyzer(self, analyzer: SecurityAnalyzerBase | None) -> None:
|
|
821
|
+
"""Set the security analyzer for the remote conversation."""
|
|
822
|
+
payload = {"security_analyzer": analyzer.model_dump() if analyzer else analyzer}
|
|
823
|
+
_send_request(
|
|
824
|
+
self._client,
|
|
825
|
+
"POST",
|
|
826
|
+
f"/api/conversations/{self._id}/security_analyzer",
|
|
827
|
+
json=payload,
|
|
828
|
+
)
|
|
829
|
+
|
|
830
|
+
def reject_pending_actions(self, reason: str = "User rejected the action") -> None:
|
|
831
|
+
# Equivalent to rejecting confirmation: pause
|
|
832
|
+
_send_request(
|
|
833
|
+
self._client,
|
|
834
|
+
"POST",
|
|
835
|
+
f"/api/conversations/{self._id}/events/respond_to_confirmation",
|
|
836
|
+
json={"accept": False, "reason": reason},
|
|
837
|
+
)
|
|
838
|
+
|
|
839
|
+
def pause(self) -> None:
|
|
840
|
+
_send_request(self._client, "POST", f"/api/conversations/{self._id}/pause")
|
|
841
|
+
|
|
842
|
+
def update_secrets(self, secrets: Mapping[str, SecretValue]) -> None:
|
|
843
|
+
# Convert SecretValue to strings for JSON serialization
|
|
844
|
+
# SecretValue can be str or callable, we need to handle both
|
|
845
|
+
serializable_secrets = {}
|
|
846
|
+
for key, value in secrets.items():
|
|
847
|
+
if callable(value):
|
|
848
|
+
# If it's a callable, call it to get the actual secret
|
|
849
|
+
serializable_secrets[key] = value()
|
|
850
|
+
else:
|
|
851
|
+
# If it's already a string, use it directly
|
|
852
|
+
serializable_secrets[key] = value
|
|
853
|
+
|
|
854
|
+
payload = {"secrets": serializable_secrets}
|
|
855
|
+
_send_request(
|
|
856
|
+
self._client, "POST", f"/api/conversations/{self._id}/secrets", json=payload
|
|
857
|
+
)
|
|
858
|
+
|
|
859
|
+
def ask_agent(self, question: str) -> str:
|
|
860
|
+
"""Ask the agent a simple, stateless question and get a direct LLM response.
|
|
861
|
+
|
|
862
|
+
This bypasses the normal conversation flow and does **not** modify, persist,
|
|
863
|
+
or become part of the conversation state. The request is not remembered by
|
|
864
|
+
the main agent, no events are recorded, and execution status is untouched.
|
|
865
|
+
It is also thread-safe and may be called while `conversation.run()` is
|
|
866
|
+
executing in another thread.
|
|
867
|
+
|
|
868
|
+
Args:
|
|
869
|
+
question: A simple string question to ask the agent
|
|
870
|
+
|
|
871
|
+
Returns:
|
|
872
|
+
A string response from the agent
|
|
873
|
+
"""
|
|
874
|
+
# For remote conversations, delegate to the server endpoint
|
|
875
|
+
payload = {"question": question}
|
|
876
|
+
|
|
877
|
+
resp = _send_request(
|
|
878
|
+
self._client,
|
|
879
|
+
"POST",
|
|
880
|
+
f"/api/conversations/{self._id}/ask_agent",
|
|
881
|
+
json=payload,
|
|
882
|
+
)
|
|
883
|
+
data = resp.json()
|
|
884
|
+
return data["response"]
|
|
885
|
+
|
|
886
|
+
@observe(name="conversation.generate_title", ignore_inputs=["llm"])
|
|
887
|
+
def generate_title(self, llm: LLM | None = None, max_length: int = 50) -> str:
|
|
888
|
+
"""Generate a title for the conversation based on the first user message.
|
|
889
|
+
|
|
890
|
+
Args:
|
|
891
|
+
llm: Optional LLM to use for title generation. If provided, its usage_id
|
|
892
|
+
will be sent to the server. If not provided, uses the agent's LLM.
|
|
893
|
+
max_length: Maximum length of the generated title.
|
|
894
|
+
|
|
895
|
+
Returns:
|
|
896
|
+
A generated title for the conversation.
|
|
897
|
+
"""
|
|
898
|
+
# For remote conversations, delegate to the server endpoint
|
|
899
|
+
payload = {
|
|
900
|
+
"max_length": max_length,
|
|
901
|
+
"llm": llm.model_dump(mode="json", context={"expose_secrets": True})
|
|
902
|
+
if llm
|
|
903
|
+
else None,
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
resp = _send_request(
|
|
907
|
+
self._client,
|
|
908
|
+
"POST",
|
|
909
|
+
f"/api/conversations/{self._id}/generate_title",
|
|
910
|
+
json=payload,
|
|
911
|
+
)
|
|
912
|
+
data = resp.json()
|
|
913
|
+
return data["title"]
|
|
914
|
+
|
|
915
|
+
def condense(self) -> None:
|
|
916
|
+
"""Force condensation of the conversation history.
|
|
917
|
+
|
|
918
|
+
This method sends a condensation request to the remote agent server.
|
|
919
|
+
The server will use the existing condensation request pattern to trigger
|
|
920
|
+
condensation if a condenser is configured and handles condensation requests.
|
|
921
|
+
|
|
922
|
+
The condensation will be applied on the server side and will modify the
|
|
923
|
+
conversation state by adding a condensation event to the history.
|
|
924
|
+
|
|
925
|
+
Raises:
|
|
926
|
+
HTTPError: If the server returns an error (e.g., no condenser configured).
|
|
927
|
+
"""
|
|
928
|
+
_send_request(self._client, "POST", f"/api/conversations/{self._id}/condense")
|
|
929
|
+
|
|
930
|
+
def close(self) -> None:
|
|
931
|
+
"""Close the conversation and clean up resources.
|
|
932
|
+
|
|
933
|
+
Note: We don't close self._client here because it's shared with the workspace.
|
|
934
|
+
The workspace owns the client and will close it during its own cleanup.
|
|
935
|
+
Closing it here would prevent the workspace from making cleanup API calls.
|
|
936
|
+
"""
|
|
937
|
+
if self._cleanup_initiated:
|
|
938
|
+
return
|
|
939
|
+
self._cleanup_initiated = True
|
|
940
|
+
if self._hook_processor is not None:
|
|
941
|
+
self._hook_processor.run_session_end()
|
|
942
|
+
try:
|
|
943
|
+
# Stop WebSocket client if it exists
|
|
944
|
+
if self._ws_client:
|
|
945
|
+
self._ws_client.stop()
|
|
946
|
+
self._ws_client = None
|
|
947
|
+
except Exception:
|
|
948
|
+
pass
|
|
949
|
+
|
|
950
|
+
self._end_observability_span()
|
|
951
|
+
|
|
952
|
+
def __del__(self) -> None:
|
|
953
|
+
try:
|
|
954
|
+
self.close()
|
|
955
|
+
except Exception:
|
|
956
|
+
pass
|