openhands-sdk 1.7.0__py3-none-any.whl

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