openhands-agent-server 1.8.2__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/agent_server/__init__.py +0 -0
- openhands/agent_server/__main__.py +118 -0
- openhands/agent_server/api.py +331 -0
- openhands/agent_server/bash_router.py +105 -0
- openhands/agent_server/bash_service.py +379 -0
- openhands/agent_server/config.py +187 -0
- openhands/agent_server/conversation_router.py +321 -0
- openhands/agent_server/conversation_service.py +692 -0
- openhands/agent_server/dependencies.py +72 -0
- openhands/agent_server/desktop_router.py +47 -0
- openhands/agent_server/desktop_service.py +212 -0
- openhands/agent_server/docker/Dockerfile +244 -0
- openhands/agent_server/docker/build.py +825 -0
- openhands/agent_server/docker/wallpaper.svg +22 -0
- openhands/agent_server/env_parser.py +460 -0
- openhands/agent_server/event_router.py +204 -0
- openhands/agent_server/event_service.py +648 -0
- openhands/agent_server/file_router.py +121 -0
- openhands/agent_server/git_router.py +34 -0
- openhands/agent_server/logging_config.py +56 -0
- openhands/agent_server/middleware.py +32 -0
- openhands/agent_server/models.py +307 -0
- openhands/agent_server/openapi.py +21 -0
- openhands/agent_server/pub_sub.py +80 -0
- openhands/agent_server/py.typed +0 -0
- openhands/agent_server/server_details_router.py +43 -0
- openhands/agent_server/sockets.py +173 -0
- openhands/agent_server/tool_preload_service.py +76 -0
- openhands/agent_server/tool_router.py +22 -0
- openhands/agent_server/utils.py +63 -0
- openhands/agent_server/vscode_extensions/openhands-settings/extension.js +22 -0
- openhands/agent_server/vscode_extensions/openhands-settings/package.json +12 -0
- openhands/agent_server/vscode_router.py +70 -0
- openhands/agent_server/vscode_service.py +232 -0
- openhands_agent_server-1.8.2.dist-info/METADATA +15 -0
- openhands_agent_server-1.8.2.dist-info/RECORD +39 -0
- openhands_agent_server-1.8.2.dist-info/WHEEL +5 -0
- openhands_agent_server-1.8.2.dist-info/entry_points.txt +2 -0
- openhands_agent_server-1.8.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
|
|
7
|
+
from openhands.agent_server.models import (
|
|
8
|
+
ConfirmationResponseRequest,
|
|
9
|
+
EventPage,
|
|
10
|
+
EventSortOrder,
|
|
11
|
+
StoredConversation,
|
|
12
|
+
)
|
|
13
|
+
from openhands.agent_server.pub_sub import PubSub, Subscriber
|
|
14
|
+
from openhands.agent_server.utils import utc_now
|
|
15
|
+
from openhands.sdk import LLM, Agent, AgentBase, Event, Message, get_logger
|
|
16
|
+
from openhands.sdk.conversation.impl.local_conversation import LocalConversation
|
|
17
|
+
from openhands.sdk.conversation.secret_registry import SecretValue
|
|
18
|
+
from openhands.sdk.conversation.state import (
|
|
19
|
+
ConversationExecutionStatus,
|
|
20
|
+
ConversationState,
|
|
21
|
+
)
|
|
22
|
+
from openhands.sdk.event import AgentErrorEvent
|
|
23
|
+
from openhands.sdk.event.conversation_state import ConversationStateUpdateEvent
|
|
24
|
+
from openhands.sdk.event.llm_completion_log import LLMCompletionLogEvent
|
|
25
|
+
from openhands.sdk.security.analyzer import SecurityAnalyzerBase
|
|
26
|
+
from openhands.sdk.security.confirmation_policy import ConfirmationPolicyBase
|
|
27
|
+
from openhands.sdk.utils.async_utils import AsyncCallbackWrapper
|
|
28
|
+
from openhands.sdk.utils.cipher import Cipher
|
|
29
|
+
from openhands.sdk.workspace import LocalWorkspace
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
logger = get_logger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class EventService:
|
|
37
|
+
"""
|
|
38
|
+
Event service for a conversation running locally, analogous to a conversation
|
|
39
|
+
in the SDK. Async mostly for forward compatibility
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
stored: StoredConversation
|
|
43
|
+
conversations_dir: Path
|
|
44
|
+
cipher: Cipher | None = None
|
|
45
|
+
_conversation: LocalConversation | None = field(default=None, init=False)
|
|
46
|
+
_pub_sub: PubSub[Event] = field(default_factory=lambda: PubSub[Event](), init=False)
|
|
47
|
+
_run_task: asyncio.Task | None = field(default=None, init=False)
|
|
48
|
+
_run_lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def conversation_dir(self):
|
|
52
|
+
return self.conversations_dir / self.stored.id.hex
|
|
53
|
+
|
|
54
|
+
async def load_meta(self):
|
|
55
|
+
meta_file = self.conversation_dir / "meta.json"
|
|
56
|
+
self.stored = StoredConversation.model_validate_json(
|
|
57
|
+
meta_file.read_text(),
|
|
58
|
+
context={
|
|
59
|
+
"cipher": self.cipher,
|
|
60
|
+
},
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
async def save_meta(self):
|
|
64
|
+
self.stored.updated_at = utc_now()
|
|
65
|
+
meta_file = self.conversation_dir / "meta.json"
|
|
66
|
+
meta_file.write_text(
|
|
67
|
+
self.stored.model_dump_json(
|
|
68
|
+
context={
|
|
69
|
+
"cipher": self.cipher,
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def get_conversation(self):
|
|
75
|
+
if not self._conversation:
|
|
76
|
+
raise ValueError("inactive_service")
|
|
77
|
+
return self._conversation
|
|
78
|
+
|
|
79
|
+
def _get_event_sync(self, event_id: str) -> Event | None:
|
|
80
|
+
"""Private sync function to get event with state lock."""
|
|
81
|
+
if not self._conversation:
|
|
82
|
+
raise ValueError("inactive_service")
|
|
83
|
+
with self._conversation._state as state:
|
|
84
|
+
index = state.events.get_index(event_id)
|
|
85
|
+
event = state.events[index]
|
|
86
|
+
return event
|
|
87
|
+
|
|
88
|
+
async def get_event(self, event_id: str) -> Event | None:
|
|
89
|
+
if not self._conversation:
|
|
90
|
+
raise ValueError("inactive_service")
|
|
91
|
+
loop = asyncio.get_running_loop()
|
|
92
|
+
return await loop.run_in_executor(None, self._get_event_sync, event_id)
|
|
93
|
+
|
|
94
|
+
def _search_events_sync(
|
|
95
|
+
self,
|
|
96
|
+
page_id: str | None = None,
|
|
97
|
+
limit: int = 100,
|
|
98
|
+
kind: str | None = None,
|
|
99
|
+
source: str | None = None,
|
|
100
|
+
body: str | None = None,
|
|
101
|
+
sort_order: EventSortOrder = EventSortOrder.TIMESTAMP,
|
|
102
|
+
timestamp__gte: datetime | None = None,
|
|
103
|
+
timestamp__lt: datetime | None = None,
|
|
104
|
+
) -> EventPage:
|
|
105
|
+
"""Private sync function to search events with state lock."""
|
|
106
|
+
if not self._conversation:
|
|
107
|
+
raise ValueError("inactive_service")
|
|
108
|
+
|
|
109
|
+
# Convert datetime to ISO string for comparison (ISO strings are comparable)
|
|
110
|
+
timestamp_gte_str = timestamp__gte.isoformat() if timestamp__gte else None
|
|
111
|
+
timestamp_lt_str = timestamp__lt.isoformat() if timestamp__lt else None
|
|
112
|
+
|
|
113
|
+
# Collect all events
|
|
114
|
+
all_events = []
|
|
115
|
+
with self._conversation._state as state:
|
|
116
|
+
for event in state.events:
|
|
117
|
+
# Apply kind filter if provided
|
|
118
|
+
if (
|
|
119
|
+
kind is not None
|
|
120
|
+
and f"{event.__class__.__module__}.{event.__class__.__name__}"
|
|
121
|
+
!= kind
|
|
122
|
+
):
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
# Apply source filter if provided
|
|
126
|
+
if source is not None and event.source != source:
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
# Apply body filter if provided (case-insensitive substring match)
|
|
130
|
+
if body is not None:
|
|
131
|
+
if not self._event_matches_body(event, body):
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
# Apply timestamp filters if provided (ISO string comparison)
|
|
135
|
+
if (
|
|
136
|
+
timestamp_gte_str is not None
|
|
137
|
+
and event.timestamp < timestamp_gte_str
|
|
138
|
+
):
|
|
139
|
+
continue
|
|
140
|
+
if timestamp_lt_str is not None and event.timestamp >= timestamp_lt_str:
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
all_events.append(event)
|
|
144
|
+
|
|
145
|
+
# Sort events based on sort_order
|
|
146
|
+
if sort_order == EventSortOrder.TIMESTAMP:
|
|
147
|
+
all_events.sort(key=lambda x: x.timestamp)
|
|
148
|
+
elif sort_order == EventSortOrder.TIMESTAMP_DESC:
|
|
149
|
+
all_events.sort(key=lambda x: x.timestamp, reverse=True)
|
|
150
|
+
|
|
151
|
+
# Handle pagination
|
|
152
|
+
items = []
|
|
153
|
+
start_index = 0
|
|
154
|
+
|
|
155
|
+
# Find the starting point if page_id is provided
|
|
156
|
+
if page_id:
|
|
157
|
+
for i, event in enumerate(all_events):
|
|
158
|
+
if event.id == page_id:
|
|
159
|
+
start_index = i
|
|
160
|
+
break
|
|
161
|
+
|
|
162
|
+
# Collect items for this page
|
|
163
|
+
next_page_id = None
|
|
164
|
+
for i in range(start_index, len(all_events)):
|
|
165
|
+
if len(items) >= limit:
|
|
166
|
+
# We have more items, set next_page_id
|
|
167
|
+
if i < len(all_events):
|
|
168
|
+
next_page_id = all_events[i].id
|
|
169
|
+
break
|
|
170
|
+
items.append(all_events[i])
|
|
171
|
+
|
|
172
|
+
return EventPage(items=items, next_page_id=next_page_id)
|
|
173
|
+
|
|
174
|
+
async def search_events(
|
|
175
|
+
self,
|
|
176
|
+
page_id: str | None = None,
|
|
177
|
+
limit: int = 100,
|
|
178
|
+
kind: str | None = None,
|
|
179
|
+
source: str | None = None,
|
|
180
|
+
body: str | None = None,
|
|
181
|
+
sort_order: EventSortOrder = EventSortOrder.TIMESTAMP,
|
|
182
|
+
timestamp__gte: datetime | None = None,
|
|
183
|
+
timestamp__lt: datetime | None = None,
|
|
184
|
+
) -> EventPage:
|
|
185
|
+
if not self._conversation:
|
|
186
|
+
raise ValueError("inactive_service")
|
|
187
|
+
loop = asyncio.get_running_loop()
|
|
188
|
+
return await loop.run_in_executor(
|
|
189
|
+
None,
|
|
190
|
+
self._search_events_sync,
|
|
191
|
+
page_id,
|
|
192
|
+
limit,
|
|
193
|
+
kind,
|
|
194
|
+
source,
|
|
195
|
+
body,
|
|
196
|
+
sort_order,
|
|
197
|
+
timestamp__gte,
|
|
198
|
+
timestamp__lt,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def _count_events_sync(
|
|
202
|
+
self,
|
|
203
|
+
kind: str | None = None,
|
|
204
|
+
source: str | None = None,
|
|
205
|
+
body: str | None = None,
|
|
206
|
+
timestamp__gte: datetime | None = None,
|
|
207
|
+
timestamp__lt: datetime | None = None,
|
|
208
|
+
) -> int:
|
|
209
|
+
"""Private sync function to count events with state lock."""
|
|
210
|
+
if not self._conversation:
|
|
211
|
+
raise ValueError("inactive_service")
|
|
212
|
+
|
|
213
|
+
# Convert datetime to ISO string for comparison (ISO strings are comparable)
|
|
214
|
+
timestamp_gte_str = timestamp__gte.isoformat() if timestamp__gte else None
|
|
215
|
+
timestamp_lt_str = timestamp__lt.isoformat() if timestamp__lt else None
|
|
216
|
+
|
|
217
|
+
count = 0
|
|
218
|
+
with self._conversation._state as state:
|
|
219
|
+
for event in state.events:
|
|
220
|
+
# Apply kind filter if provided
|
|
221
|
+
if (
|
|
222
|
+
kind is not None
|
|
223
|
+
and f"{event.__class__.__module__}.{event.__class__.__name__}"
|
|
224
|
+
!= kind
|
|
225
|
+
):
|
|
226
|
+
continue
|
|
227
|
+
|
|
228
|
+
# Apply source filter if provided
|
|
229
|
+
if source is not None and event.source != source:
|
|
230
|
+
continue
|
|
231
|
+
|
|
232
|
+
# Apply body filter if provided (case-insensitive substring match)
|
|
233
|
+
if body is not None:
|
|
234
|
+
if not self._event_matches_body(event, body):
|
|
235
|
+
continue
|
|
236
|
+
|
|
237
|
+
# Apply timestamp filters if provided (ISO string comparison)
|
|
238
|
+
if (
|
|
239
|
+
timestamp_gte_str is not None
|
|
240
|
+
and event.timestamp < timestamp_gte_str
|
|
241
|
+
):
|
|
242
|
+
continue
|
|
243
|
+
if timestamp_lt_str is not None and event.timestamp >= timestamp_lt_str:
|
|
244
|
+
continue
|
|
245
|
+
|
|
246
|
+
count += 1
|
|
247
|
+
|
|
248
|
+
return count
|
|
249
|
+
|
|
250
|
+
async def count_events(
|
|
251
|
+
self,
|
|
252
|
+
kind: str | None = None,
|
|
253
|
+
source: str | None = None,
|
|
254
|
+
body: str | None = None,
|
|
255
|
+
timestamp__gte: datetime | None = None,
|
|
256
|
+
timestamp__lt: datetime | None = None,
|
|
257
|
+
) -> int:
|
|
258
|
+
"""Count events matching the given filters."""
|
|
259
|
+
if not self._conversation:
|
|
260
|
+
raise ValueError("inactive_service")
|
|
261
|
+
loop = asyncio.get_running_loop()
|
|
262
|
+
return await loop.run_in_executor(
|
|
263
|
+
None,
|
|
264
|
+
self._count_events_sync,
|
|
265
|
+
kind,
|
|
266
|
+
source,
|
|
267
|
+
body,
|
|
268
|
+
timestamp__gte,
|
|
269
|
+
timestamp__lt,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
def _event_matches_body(self, event: Event, body: str) -> bool:
|
|
273
|
+
"""Check if event's message content matches body filter (case-insensitive)."""
|
|
274
|
+
# Import here to avoid circular imports
|
|
275
|
+
from openhands.sdk.event.llm_convertible.message import MessageEvent
|
|
276
|
+
from openhands.sdk.llm.message import content_to_str
|
|
277
|
+
|
|
278
|
+
# Only check MessageEvent instances for body content
|
|
279
|
+
if not isinstance(event, MessageEvent):
|
|
280
|
+
return False
|
|
281
|
+
|
|
282
|
+
# Extract text content from the message
|
|
283
|
+
text_parts = content_to_str(event.llm_message.content)
|
|
284
|
+
|
|
285
|
+
# Also check extended content if present
|
|
286
|
+
if event.extended_content:
|
|
287
|
+
extended_text_parts = content_to_str(event.extended_content)
|
|
288
|
+
text_parts.extend(extended_text_parts)
|
|
289
|
+
|
|
290
|
+
# Also check reasoning content if present
|
|
291
|
+
if event.reasoning_content:
|
|
292
|
+
text_parts.append(event.reasoning_content)
|
|
293
|
+
|
|
294
|
+
# Combine all text content and perform case-insensitive substring match
|
|
295
|
+
full_text = " ".join(text_parts).lower()
|
|
296
|
+
return body.lower() in full_text
|
|
297
|
+
|
|
298
|
+
async def batch_get_events(self, event_ids: list[str]) -> list[Event | None]:
|
|
299
|
+
"""Given a list of ids, get events (Or none for any which were not found)"""
|
|
300
|
+
results = await asyncio.gather(
|
|
301
|
+
*[self.get_event(event_id) for event_id in event_ids]
|
|
302
|
+
)
|
|
303
|
+
return results
|
|
304
|
+
|
|
305
|
+
async def send_message(self, message: Message, run: bool = False):
|
|
306
|
+
if not self._conversation:
|
|
307
|
+
raise ValueError("inactive_service")
|
|
308
|
+
loop = asyncio.get_running_loop()
|
|
309
|
+
await loop.run_in_executor(None, self._conversation.send_message, message)
|
|
310
|
+
if run:
|
|
311
|
+
with self._conversation.state as state:
|
|
312
|
+
run = state.execution_status != ConversationExecutionStatus.RUNNING
|
|
313
|
+
if run:
|
|
314
|
+
loop.run_in_executor(None, self._conversation.run)
|
|
315
|
+
|
|
316
|
+
async def subscribe_to_events(self, subscriber: Subscriber[Event]) -> UUID:
|
|
317
|
+
subscriber_id = self._pub_sub.subscribe(subscriber)
|
|
318
|
+
|
|
319
|
+
# Send current state to the new subscriber immediately
|
|
320
|
+
if self._conversation:
|
|
321
|
+
state = self._conversation._state
|
|
322
|
+
with state:
|
|
323
|
+
# Create state update event with current state information
|
|
324
|
+
state_update_event = (
|
|
325
|
+
ConversationStateUpdateEvent.from_conversation_state(state)
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
# Send state update directly to the new subscriber
|
|
329
|
+
try:
|
|
330
|
+
await subscriber(state_update_event)
|
|
331
|
+
except Exception as e:
|
|
332
|
+
logger.error(
|
|
333
|
+
f"Error sending initial state to subscriber "
|
|
334
|
+
f"{subscriber_id}: {e}"
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
return subscriber_id
|
|
338
|
+
|
|
339
|
+
async def unsubscribe_from_events(self, subscriber_id: UUID) -> bool:
|
|
340
|
+
return self._pub_sub.unsubscribe(subscriber_id)
|
|
341
|
+
|
|
342
|
+
def _emit_event_from_thread(self, event: Event) -> None:
|
|
343
|
+
"""Helper to safely emit events from non-async contexts (e.g., callbacks).
|
|
344
|
+
|
|
345
|
+
This schedules event emission in the main event loop, making it safe to call
|
|
346
|
+
from callbacks that may run in different threads. Events are emitted through
|
|
347
|
+
the conversation's normal event flow to ensure they are persisted.
|
|
348
|
+
"""
|
|
349
|
+
if self._main_loop and self._main_loop.is_running() and self._conversation:
|
|
350
|
+
# Capture conversation reference for closure
|
|
351
|
+
conversation = self._conversation
|
|
352
|
+
|
|
353
|
+
# Wrap _on_event with lock acquisition to ensure thread-safe access
|
|
354
|
+
# to conversation state and event log during concurrent operations
|
|
355
|
+
def locked_on_event():
|
|
356
|
+
with conversation._state:
|
|
357
|
+
conversation._on_event(event)
|
|
358
|
+
|
|
359
|
+
# Run the locked callback in an executor to ensure the event is
|
|
360
|
+
# both persisted and sent to WebSocket subscribers
|
|
361
|
+
self._main_loop.run_in_executor(None, locked_on_event)
|
|
362
|
+
|
|
363
|
+
def _setup_llm_log_streaming(self, agent: AgentBase) -> None:
|
|
364
|
+
"""Configure LLM log callbacks to stream logs via events."""
|
|
365
|
+
for llm in agent.get_all_llms():
|
|
366
|
+
if not llm.log_completions:
|
|
367
|
+
continue
|
|
368
|
+
|
|
369
|
+
# Capture variables for closure
|
|
370
|
+
usage_id = llm.usage_id
|
|
371
|
+
model_name = llm.model
|
|
372
|
+
|
|
373
|
+
def log_callback(
|
|
374
|
+
filename: str, log_data: str, uid=usage_id, model=model_name
|
|
375
|
+
) -> None:
|
|
376
|
+
"""Callback to emit LLM completion logs as events."""
|
|
377
|
+
event = LLMCompletionLogEvent(
|
|
378
|
+
filename=filename,
|
|
379
|
+
log_data=log_data,
|
|
380
|
+
model_name=model,
|
|
381
|
+
usage_id=uid,
|
|
382
|
+
)
|
|
383
|
+
self._emit_event_from_thread(event)
|
|
384
|
+
|
|
385
|
+
llm.telemetry.set_log_completions_callback(log_callback)
|
|
386
|
+
|
|
387
|
+
def _setup_stats_streaming(self, agent: AgentBase) -> None:
|
|
388
|
+
"""Configure stats update callbacks to stream stats changes via events."""
|
|
389
|
+
|
|
390
|
+
def stats_callback() -> None:
|
|
391
|
+
"""Callback to emit stats updates."""
|
|
392
|
+
# Publish only the stats field to avoid sending entire state
|
|
393
|
+
if not self._conversation:
|
|
394
|
+
return
|
|
395
|
+
state = self._conversation._state
|
|
396
|
+
with state:
|
|
397
|
+
event = ConversationStateUpdateEvent(key="stats", value=state.stats)
|
|
398
|
+
self._emit_event_from_thread(event)
|
|
399
|
+
|
|
400
|
+
for llm in agent.get_all_llms():
|
|
401
|
+
llm.telemetry.set_stats_update_callback(stats_callback)
|
|
402
|
+
|
|
403
|
+
async def start(self):
|
|
404
|
+
# Store the main event loop for cross-thread communication
|
|
405
|
+
self._main_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop()
|
|
406
|
+
|
|
407
|
+
# self.stored contains an Agent configuration we can instantiate
|
|
408
|
+
self.conversation_dir.mkdir(parents=True, exist_ok=True)
|
|
409
|
+
workspace = self.stored.workspace
|
|
410
|
+
assert isinstance(workspace, LocalWorkspace)
|
|
411
|
+
Path(workspace.working_dir).mkdir(parents=True, exist_ok=True)
|
|
412
|
+
agent = Agent.model_validate(
|
|
413
|
+
self.stored.agent.model_dump(context={"expose_secrets": True}),
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
conversation = LocalConversation(
|
|
417
|
+
agent=agent,
|
|
418
|
+
workspace=workspace,
|
|
419
|
+
persistence_dir=str(self.conversations_dir),
|
|
420
|
+
conversation_id=self.stored.id,
|
|
421
|
+
callbacks=[
|
|
422
|
+
AsyncCallbackWrapper(self._pub_sub, loop=asyncio.get_running_loop())
|
|
423
|
+
],
|
|
424
|
+
max_iteration_per_run=self.stored.max_iterations,
|
|
425
|
+
stuck_detection=self.stored.stuck_detection,
|
|
426
|
+
visualizer=None,
|
|
427
|
+
secrets=self.stored.secrets,
|
|
428
|
+
cipher=self.cipher,
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
# Set confirmation mode if enabled
|
|
432
|
+
conversation.set_confirmation_policy(self.stored.confirmation_policy)
|
|
433
|
+
self._conversation = conversation
|
|
434
|
+
|
|
435
|
+
# Register state change callback to automatically publish updates
|
|
436
|
+
self._conversation._state.set_on_state_change(self._conversation._on_event)
|
|
437
|
+
|
|
438
|
+
# Setup LLM log streaming for remote execution
|
|
439
|
+
self._setup_llm_log_streaming(self._conversation.agent)
|
|
440
|
+
|
|
441
|
+
# Setup stats streaming for remote execution
|
|
442
|
+
self._setup_stats_streaming(self._conversation.agent)
|
|
443
|
+
|
|
444
|
+
# If the execution_status was "running" while serialized, then the
|
|
445
|
+
# conversation can't possibly be running - something is wrong
|
|
446
|
+
state = self._conversation.state
|
|
447
|
+
if state.execution_status == ConversationExecutionStatus.RUNNING:
|
|
448
|
+
state.execution_status = ConversationExecutionStatus.ERROR
|
|
449
|
+
# Add error event for the first unmatched action to inform the agent
|
|
450
|
+
unmatched_actions = ConversationState.get_unmatched_actions(state.events)
|
|
451
|
+
if unmatched_actions:
|
|
452
|
+
first_action = unmatched_actions[0]
|
|
453
|
+
error_event = AgentErrorEvent(
|
|
454
|
+
tool_name=first_action.tool_name,
|
|
455
|
+
tool_call_id=first_action.tool_call_id,
|
|
456
|
+
error=(
|
|
457
|
+
"A restart occurred while this tool was in progress. "
|
|
458
|
+
"This may indicate a fatal memory error or system crash. "
|
|
459
|
+
"The tool execution was interrupted and did not complete."
|
|
460
|
+
),
|
|
461
|
+
)
|
|
462
|
+
self._conversation._on_event(error_event)
|
|
463
|
+
|
|
464
|
+
# Publish initial state update
|
|
465
|
+
await self._publish_state_update()
|
|
466
|
+
|
|
467
|
+
async def run(self):
|
|
468
|
+
"""Run the conversation asynchronously in the background.
|
|
469
|
+
|
|
470
|
+
This method starts the conversation run in a background task and returns
|
|
471
|
+
immediately. The conversation status can be monitored via the
|
|
472
|
+
GET /api/conversations/{id} endpoint or WebSocket events.
|
|
473
|
+
|
|
474
|
+
Raises:
|
|
475
|
+
ValueError: If the service is inactive or conversation is already running.
|
|
476
|
+
"""
|
|
477
|
+
if not self._conversation:
|
|
478
|
+
raise ValueError("inactive_service")
|
|
479
|
+
|
|
480
|
+
# Use lock to make check-and-set atomic, preventing race conditions
|
|
481
|
+
async with self._run_lock:
|
|
482
|
+
# Check if already running
|
|
483
|
+
with self._conversation._state as state:
|
|
484
|
+
if state.execution_status == ConversationExecutionStatus.RUNNING:
|
|
485
|
+
raise ValueError("conversation_already_running")
|
|
486
|
+
|
|
487
|
+
# Check if there's already a running task
|
|
488
|
+
if self._run_task is not None and not self._run_task.done():
|
|
489
|
+
raise ValueError("conversation_already_running")
|
|
490
|
+
|
|
491
|
+
# Capture conversation reference for the closure
|
|
492
|
+
conversation = self._conversation
|
|
493
|
+
|
|
494
|
+
# Start run in background
|
|
495
|
+
loop = asyncio.get_running_loop()
|
|
496
|
+
|
|
497
|
+
async def _run_and_publish():
|
|
498
|
+
try:
|
|
499
|
+
await loop.run_in_executor(None, conversation.run)
|
|
500
|
+
except Exception as e:
|
|
501
|
+
logger.error(f"Error during conversation run: {e}")
|
|
502
|
+
finally:
|
|
503
|
+
# Clear task reference and publish state update
|
|
504
|
+
self._run_task = None
|
|
505
|
+
await self._publish_state_update()
|
|
506
|
+
|
|
507
|
+
# Create task but don't await it - runs in background
|
|
508
|
+
self._run_task = asyncio.create_task(_run_and_publish())
|
|
509
|
+
|
|
510
|
+
async def respond_to_confirmation(self, request: ConfirmationResponseRequest):
|
|
511
|
+
if request.accept:
|
|
512
|
+
try:
|
|
513
|
+
await self.run()
|
|
514
|
+
except ValueError as e:
|
|
515
|
+
# Treat "already running" as a no-op success
|
|
516
|
+
if str(e) == "conversation_already_running":
|
|
517
|
+
logger.debug(
|
|
518
|
+
"Confirmation accepted but conversation already running"
|
|
519
|
+
)
|
|
520
|
+
else:
|
|
521
|
+
raise
|
|
522
|
+
else:
|
|
523
|
+
await self.reject_pending_actions(request.reason)
|
|
524
|
+
|
|
525
|
+
async def reject_pending_actions(self, reason: str):
|
|
526
|
+
"""Reject all pending actions and publish updated state."""
|
|
527
|
+
if not self._conversation:
|
|
528
|
+
raise ValueError("inactive_service")
|
|
529
|
+
loop = asyncio.get_running_loop()
|
|
530
|
+
await loop.run_in_executor(
|
|
531
|
+
None, self._conversation.reject_pending_actions, reason
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
async def pause(self):
|
|
535
|
+
if self._conversation:
|
|
536
|
+
loop = asyncio.get_running_loop()
|
|
537
|
+
await loop.run_in_executor(None, self._conversation.pause)
|
|
538
|
+
# Publish state update after pause to ensure stats are updated
|
|
539
|
+
await self._publish_state_update()
|
|
540
|
+
|
|
541
|
+
async def update_secrets(self, secrets: dict[str, SecretValue]):
|
|
542
|
+
"""Update secrets in the conversation."""
|
|
543
|
+
if not self._conversation:
|
|
544
|
+
raise ValueError("inactive_service")
|
|
545
|
+
loop = asyncio.get_running_loop()
|
|
546
|
+
await loop.run_in_executor(None, self._conversation.update_secrets, secrets)
|
|
547
|
+
|
|
548
|
+
async def set_confirmation_policy(self, policy: ConfirmationPolicyBase):
|
|
549
|
+
"""Set the confirmation policy for the conversation."""
|
|
550
|
+
if not self._conversation:
|
|
551
|
+
raise ValueError("inactive_service")
|
|
552
|
+
loop = asyncio.get_running_loop()
|
|
553
|
+
await loop.run_in_executor(
|
|
554
|
+
None, self._conversation.set_confirmation_policy, policy
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
async def set_security_analyzer(
|
|
558
|
+
self, security_analyzer: SecurityAnalyzerBase | None
|
|
559
|
+
):
|
|
560
|
+
"""Set the security analyzer for the conversation."""
|
|
561
|
+
if not self._conversation:
|
|
562
|
+
raise ValueError("inactive_service")
|
|
563
|
+
loop = asyncio.get_running_loop()
|
|
564
|
+
await loop.run_in_executor(
|
|
565
|
+
None, self._conversation.set_security_analyzer, security_analyzer
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
async def close(self):
|
|
569
|
+
await self._pub_sub.close()
|
|
570
|
+
if self._conversation:
|
|
571
|
+
loop = asyncio.get_running_loop()
|
|
572
|
+
loop.run_in_executor(None, self._conversation.close)
|
|
573
|
+
|
|
574
|
+
async def generate_title(
|
|
575
|
+
self, llm: "LLM | None" = None, max_length: int = 50
|
|
576
|
+
) -> str:
|
|
577
|
+
"""Generate a title for the conversation.
|
|
578
|
+
|
|
579
|
+
Resolves the provided LLM via the conversation's registry if a usage_id is
|
|
580
|
+
present, registering it if needed. Then delegates to LocalConversation in an
|
|
581
|
+
executor to avoid blocking the event loop.
|
|
582
|
+
"""
|
|
583
|
+
if not self._conversation:
|
|
584
|
+
raise ValueError("inactive_service")
|
|
585
|
+
|
|
586
|
+
resolved_llm = llm
|
|
587
|
+
if llm is not None:
|
|
588
|
+
usage_id = llm.usage_id
|
|
589
|
+
try:
|
|
590
|
+
resolved_llm = self._conversation.llm_registry.get(usage_id)
|
|
591
|
+
except KeyError:
|
|
592
|
+
self._conversation.llm_registry.add(llm)
|
|
593
|
+
resolved_llm = llm
|
|
594
|
+
|
|
595
|
+
loop = asyncio.get_running_loop()
|
|
596
|
+
return await loop.run_in_executor(
|
|
597
|
+
None, self._conversation.generate_title, resolved_llm, max_length
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
async def ask_agent(self, question: str) -> str:
|
|
601
|
+
"""Ask the agent a simple question without affecting conversation state.
|
|
602
|
+
|
|
603
|
+
Delegates to LocalConversation in an executor to avoid blocking the event loop.
|
|
604
|
+
"""
|
|
605
|
+
if not self._conversation:
|
|
606
|
+
raise ValueError("inactive_service")
|
|
607
|
+
|
|
608
|
+
loop = asyncio.get_running_loop()
|
|
609
|
+
return await loop.run_in_executor(None, self._conversation.ask_agent, question)
|
|
610
|
+
|
|
611
|
+
async def condense(self) -> None:
|
|
612
|
+
"""Force condensation of the conversation history.
|
|
613
|
+
|
|
614
|
+
Delegates to LocalConversation in an executor to avoid blocking the event loop.
|
|
615
|
+
"""
|
|
616
|
+
if not self._conversation:
|
|
617
|
+
raise ValueError("inactive_service")
|
|
618
|
+
|
|
619
|
+
loop = asyncio.get_running_loop()
|
|
620
|
+
return await loop.run_in_executor(None, self._conversation.condense)
|
|
621
|
+
|
|
622
|
+
async def get_state(self) -> ConversationState:
|
|
623
|
+
if not self._conversation:
|
|
624
|
+
raise ValueError("inactive_service")
|
|
625
|
+
return self._conversation._state
|
|
626
|
+
|
|
627
|
+
async def _publish_state_update(self):
|
|
628
|
+
"""Publish a ConversationStateUpdateEvent with the current state."""
|
|
629
|
+
if not self._conversation:
|
|
630
|
+
return
|
|
631
|
+
|
|
632
|
+
state = self._conversation._state
|
|
633
|
+
with state:
|
|
634
|
+
state_update_event = ConversationStateUpdateEvent.from_conversation_state(
|
|
635
|
+
state
|
|
636
|
+
)
|
|
637
|
+
await self._pub_sub(state_update_event)
|
|
638
|
+
|
|
639
|
+
async def __aenter__(self):
|
|
640
|
+
await self.start()
|
|
641
|
+
return self
|
|
642
|
+
|
|
643
|
+
async def __aexit__(self, exc_type, exc_value, traceback):
|
|
644
|
+
await self.save_meta()
|
|
645
|
+
await self.close()
|
|
646
|
+
|
|
647
|
+
def is_open(self) -> bool:
|
|
648
|
+
return bool(self._conversation)
|