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.
Files changed (39) hide show
  1. openhands/agent_server/__init__.py +0 -0
  2. openhands/agent_server/__main__.py +118 -0
  3. openhands/agent_server/api.py +331 -0
  4. openhands/agent_server/bash_router.py +105 -0
  5. openhands/agent_server/bash_service.py +379 -0
  6. openhands/agent_server/config.py +187 -0
  7. openhands/agent_server/conversation_router.py +321 -0
  8. openhands/agent_server/conversation_service.py +692 -0
  9. openhands/agent_server/dependencies.py +72 -0
  10. openhands/agent_server/desktop_router.py +47 -0
  11. openhands/agent_server/desktop_service.py +212 -0
  12. openhands/agent_server/docker/Dockerfile +244 -0
  13. openhands/agent_server/docker/build.py +825 -0
  14. openhands/agent_server/docker/wallpaper.svg +22 -0
  15. openhands/agent_server/env_parser.py +460 -0
  16. openhands/agent_server/event_router.py +204 -0
  17. openhands/agent_server/event_service.py +648 -0
  18. openhands/agent_server/file_router.py +121 -0
  19. openhands/agent_server/git_router.py +34 -0
  20. openhands/agent_server/logging_config.py +56 -0
  21. openhands/agent_server/middleware.py +32 -0
  22. openhands/agent_server/models.py +307 -0
  23. openhands/agent_server/openapi.py +21 -0
  24. openhands/agent_server/pub_sub.py +80 -0
  25. openhands/agent_server/py.typed +0 -0
  26. openhands/agent_server/server_details_router.py +43 -0
  27. openhands/agent_server/sockets.py +173 -0
  28. openhands/agent_server/tool_preload_service.py +76 -0
  29. openhands/agent_server/tool_router.py +22 -0
  30. openhands/agent_server/utils.py +63 -0
  31. openhands/agent_server/vscode_extensions/openhands-settings/extension.js +22 -0
  32. openhands/agent_server/vscode_extensions/openhands-settings/package.json +12 -0
  33. openhands/agent_server/vscode_router.py +70 -0
  34. openhands/agent_server/vscode_service.py +232 -0
  35. openhands_agent_server-1.8.2.dist-info/METADATA +15 -0
  36. openhands_agent_server-1.8.2.dist-info/RECORD +39 -0
  37. openhands_agent_server-1.8.2.dist-info/WHEEL +5 -0
  38. openhands_agent_server-1.8.2.dist-info/entry_points.txt +2 -0
  39. 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)