openhands-agent-server 1.49.0__tar.gz → 1.49.2__tar.gz

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 (94) hide show
  1. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/PKG-INFO +1 -1
  2. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/conversation_registry.py +17 -0
  3. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/conversation_router.py +32 -4
  4. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/conversation_service.py +34 -30
  5. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/dependencies.py +12 -1
  6. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/docker_runtime/provisioning.py +19 -1
  7. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/docker_runtime/registry.py +58 -3
  8. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/docker_runtime/routers.py +50 -34
  9. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/event_router.py +21 -7
  10. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/event_service.py +26 -18
  11. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/models.py +7 -0
  12. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/runtime_router.py +3 -1
  13. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands_agent_server.egg-info/PKG-INFO +1 -1
  14. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/pyproject.toml +1 -1
  15. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/__init__.py +0 -0
  16. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/__main__.py +0 -0
  17. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/_secret_redaction.py +0 -0
  18. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/_secrets_exposure.py +0 -0
  19. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/agent_profiles_router.py +0 -0
  20. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/api.py +0 -0
  21. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/auth_router.py +0 -0
  22. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/bash_router.py +0 -0
  23. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/bash_service.py +0 -0
  24. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/canvas_extensions/__init__.py +0 -0
  25. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/canvas_extensions/installed.py +0 -0
  26. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/canvas_extensions/manifest.py +0 -0
  27. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/canvas_extensions_router.py +0 -0
  28. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/config.py +0 -0
  29. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/conversation_lease.py +0 -0
  30. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/credential_binding.py +0 -0
  31. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/docker/Dockerfile +0 -0
  32. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/docker/build.py +0 -0
  33. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/docker_runtime/__init__.py +0 -0
  34. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/docker_runtime/mediation.py +0 -0
  35. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/docker_runtime/proxy.py +0 -0
  36. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/env_parser.py +0 -0
  37. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/file_router.py +0 -0
  38. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/git_router.py +0 -0
  39. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/hooks_router.py +0 -0
  40. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/hooks_service.py +0 -0
  41. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/init_router.py +0 -0
  42. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/llm_router.py +0 -0
  43. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/logging_config.py +0 -0
  44. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/mcp_oauth_store.py +0 -0
  45. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/mcp_router.py +0 -0
  46. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/middleware.py +0 -0
  47. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/openai/__init__.py +0 -0
  48. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/openai/models.py +0 -0
  49. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/openai/router.py +0 -0
  50. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/openai/service.py +0 -0
  51. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/openapi.py +0 -0
  52. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/persistence/__init__.py +0 -0
  53. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/persistence/models.py +0 -0
  54. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/persistence/store.py +0 -0
  55. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/plugins_router.py +0 -0
  56. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/plugins_service.py +0 -0
  57. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/profiles_router.py +0 -0
  58. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/provider_connections_router.py +0 -0
  59. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/pub_sub.py +0 -0
  60. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/py.typed +0 -0
  61. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/server_details_router.py +0 -0
  62. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/session_protocol.py +0 -0
  63. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/session_socket.py +0 -0
  64. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/settings_router.py +0 -0
  65. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/skills_router.py +0 -0
  66. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/skills_service.py +0 -0
  67. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/sockets.py +0 -0
  68. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/sub_agents_router.py +0 -0
  69. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/telemetry/__init__.py +0 -0
  70. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/telemetry/factory.py +0 -0
  71. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/telemetry/http_exporter.py +0 -0
  72. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/telemetry/models.py +0 -0
  73. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/telemetry/policy.py +0 -0
  74. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/telemetry/posthog_exporter.py +0 -0
  75. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/telemetry/sanitizer.py +0 -0
  76. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/telemetry/service.py +0 -0
  77. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/telemetry/sink.py +0 -0
  78. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/telemetry/subscriber.py +0 -0
  79. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/telemetry_types.py +0 -0
  80. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/tool_preload_service.py +0 -0
  81. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/tool_router.py +0 -0
  82. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/utils.py +0 -0
  83. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/vscode_extensions/openhands-settings/extension.js +0 -0
  84. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/vscode_extensions/openhands-settings/package.json +0 -0
  85. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/vscode_router.py +0 -0
  86. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/vscode_service.py +0 -0
  87. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/workspace_router.py +0 -0
  88. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/workspaces_router.py +0 -0
  89. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands_agent_server.egg-info/SOURCES.txt +0 -0
  90. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands_agent_server.egg-info/dependency_links.txt +0 -0
  91. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands_agent_server.egg-info/entry_points.txt +0 -0
  92. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands_agent_server.egg-info/requires.txt +0 -0
  93. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands_agent_server.egg-info/top_level.txt +0 -0
  94. {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openhands-agent-server
3
- Version: 1.49.0
3
+ Version: 1.49.2
4
4
  Summary: OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent
5
5
  Project-URL: Source, https://github.com/OpenHands/software-agent-sdk
6
6
  Project-URL: Homepage, https://github.com/OpenHands/software-agent-sdk
@@ -3,10 +3,15 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from typing import TYPE_CHECKING
6
+ from uuid import UUID
6
7
 
7
8
  from fastapi import APIRouter
8
9
 
9
10
  from openhands.agent_server.config import Config
11
+ from openhands.agent_server.models import (
12
+ ConversationRuntimeInfo,
13
+ ConversationRuntimeStatus,
14
+ )
10
15
 
11
16
 
12
17
  if TYPE_CHECKING:
@@ -22,6 +27,18 @@ class ConversationRegistry:
22
27
  def configure_service(self, service: ConversationService) -> None:
23
28
  """Connect runtime-specific persistence to the shared catalog."""
24
29
 
30
+ def runtime_info(self, _conversation_id: UUID) -> ConversationRuntimeInfo:
31
+ """Describe whether a catalog conversation has an executable runtime."""
32
+ return ConversationRuntimeInfo(
33
+ runtime_status=ConversationRuntimeStatus.AVAILABLE,
34
+ can_resume=True,
35
+ )
36
+
37
+ @property
38
+ def serves_persisted_event_reads(self) -> bool:
39
+ """Whether read-only event routes should use shared persisted storage."""
40
+ return False
41
+
25
42
  async def start(self) -> None:
26
43
  """Start resources owned by this registry."""
27
44
 
@@ -19,6 +19,7 @@ from openhands.agent_server._secrets_exposure import (
19
19
  decrypt_incoming_llm_secrets,
20
20
  get_cipher,
21
21
  )
22
+ from openhands.agent_server.conversation_registry import ConversationRegistry
22
23
  from openhands.agent_server.conversation_service import (
23
24
  ConversationService,
24
25
  InvalidParentConversation,
@@ -89,9 +90,21 @@ START_CONVERSATION_EXAMPLES = [
89
90
  # Read methods
90
91
 
91
92
 
93
+ def _with_runtime_info(
94
+ request: Request, conversation: ConversationInfo
95
+ ) -> ConversationInfo:
96
+ registry = getattr(request.app.state, "conversation_registry", None)
97
+ if not isinstance(registry, ConversationRegistry):
98
+ return conversation
99
+ return conversation.model_copy(
100
+ update={"runtime_info": registry.runtime_info(conversation.id)}
101
+ )
102
+
103
+
92
104
  @conversation_catalog_router.get("/search", include_in_schema=False)
93
105
  @conversation_router.get("/search")
94
106
  async def search_conversations(
107
+ request: Request,
95
108
  page_id: Annotated[
96
109
  str | None,
97
110
  Query(title="Optional next_page_id from the previously returned page"),
@@ -127,6 +140,9 @@ async def search_conversations(
127
140
  ]
128
141
  }
129
142
  )
143
+ page = page.model_copy(
144
+ update={"items": [_with_runtime_info(request, item) for item in page.items]}
145
+ )
130
146
  return page
131
147
 
132
148
 
@@ -149,6 +165,7 @@ async def count_conversations(
149
165
  )
150
166
  async def get_conversation(
151
167
  conversation_id: UUID,
168
+ request: Request,
152
169
  include_skills: Annotated[bool, Query(title=INCLUDE_SKILLS_PARAM_TITLE)] = False,
153
170
  conversation_service: ConversationService = Depends(get_conversation_service),
154
171
  ) -> ConversationInfo:
@@ -158,30 +175,36 @@ async def get_conversation(
158
175
  raise HTTPException(status.HTTP_404_NOT_FOUND)
159
176
  if not include_skills:
160
177
  conversation = trim_conversation_response_skills(conversation)
161
- return conversation
178
+ return _with_runtime_info(request, conversation)
162
179
 
163
180
 
164
181
  @conversation_router.get("/{conversation_id}/runtime")
165
182
  async def get_local_conversation_runtime(
166
183
  conversation_id: UUID,
184
+ request: Request,
167
185
  conversation_service: ConversationService = Depends(get_conversation_service),
168
186
  ) -> ConversationRuntimeInfo:
169
187
  """Inspect the always-available in-process runtime."""
170
188
  if await conversation_service.get_conversation(conversation_id) is None:
171
189
  raise HTTPException(status.HTTP_404_NOT_FOUND)
190
+ registry = getattr(request.app.state, "conversation_registry", None)
191
+ if isinstance(registry, ConversationRegistry):
192
+ return registry.runtime_info(conversation_id)
172
193
  return ConversationRuntimeInfo(
173
- runtime_status=ConversationRuntimeStatus.AVAILABLE,
174
- can_resume=True,
194
+ runtime_status=ConversationRuntimeStatus.AVAILABLE, can_resume=True
175
195
  )
176
196
 
177
197
 
178
198
  @conversation_router.post("/{conversation_id}/runtime/reprovision")
179
199
  async def reprovision_local_conversation_runtime(
180
200
  conversation_id: UUID,
201
+ request: Request,
181
202
  conversation_service: ConversationService = Depends(get_conversation_service),
182
203
  ) -> ConversationRuntimeInfo:
183
204
  """Return local runtime state; local mode has no infrastructure to provision."""
184
- return await get_local_conversation_runtime(conversation_id, conversation_service)
205
+ return await get_local_conversation_runtime(
206
+ conversation_id, request, conversation_service
207
+ )
185
208
 
186
209
 
187
210
  @conversation_router.get(
@@ -207,6 +230,7 @@ async def get_conversation_agent_final_response(
207
230
 
208
231
  @conversation_router.get("")
209
232
  async def batch_get_conversations(
233
+ request: Request,
210
234
  ids: Annotated[list[UUID], Query()],
211
235
  include_skills: Annotated[bool, Query(title=INCLUDE_SKILLS_PARAM_TITLE)] = False,
212
236
  conversation_service: ConversationService = Depends(get_conversation_service),
@@ -215,6 +239,10 @@ async def batch_get_conversations(
215
239
  any missing item"""
216
240
  assert len(ids) < 100
217
241
  conversations = await conversation_service.batch_get_conversations(ids)
242
+ conversations = [
243
+ _with_runtime_info(request, conversation) if conversation is not None else None
244
+ for conversation in conversations
245
+ ]
218
246
  if not include_skills:
219
247
  return [
220
248
  trim_conversation_response_skills(c) if c is not None else None
@@ -704,7 +704,6 @@ class ConversationService:
704
704
  default=Path("/tmp/conversation-worktrees")
705
705
  )
706
706
  acp_skill_sourcing: ACPSkillSourcing = "native"
707
- sync_external_catalog: bool = False
708
707
  _event_services: dict[UUID, EventService] | None = field(default=None, init=False)
709
708
  _conversation_records: dict[UUID, _ConversationRecord] = field(
710
709
  default_factory=dict, init=False
@@ -1108,34 +1107,31 @@ class ConversationService:
1108
1107
  record.cached_info = None
1109
1108
  record.state_signature = signature
1110
1109
 
1111
- async def _reconcile_active_records(
1112
- self, conversation_id: UUID | None = None
1113
- ) -> None:
1114
- """Discover externally persisted records and injected live services."""
1110
+ async def refresh_persisted_conversation(self, conversation_id: UUID) -> None:
1111
+ """Refresh one catalog record changed by an external runtime."""
1112
+ event_services = self._event_services
1113
+ if event_services is None:
1114
+ raise ValueError("inactive_service")
1115
+ disk_records = await asyncio.to_thread(self._load_catalog_sync, conversation_id)
1116
+ if conversation_id not in disk_records:
1117
+ event_service = event_services.get(conversation_id)
1118
+ if event_service is None or not event_service.is_open():
1119
+ self._conversation_records.pop(conversation_id, None)
1120
+ return
1121
+ record = disk_records[conversation_id]
1122
+ event_service = event_services.get(conversation_id)
1123
+ if event_service is not None and event_service.is_open():
1124
+ return
1125
+ existing = self._conversation_records.setdefault(conversation_id, record)
1126
+ if existing.stored != record.stored:
1127
+ existing.stored = record.stored
1128
+ existing.cached_info = None
1129
+
1130
+ async def _reconcile_active_records(self) -> None:
1131
+ """Add injected live services to the in-memory catalog."""
1115
1132
  event_services = self._event_services
1116
1133
  if event_services is None:
1117
1134
  raise ValueError("inactive_service")
1118
- if self.sync_external_catalog:
1119
- disk_records = await asyncio.to_thread(
1120
- self._load_catalog_sync, conversation_id
1121
- )
1122
- stale_ids = (
1123
- {conversation_id}
1124
- if conversation_id is not None
1125
- else set(self._conversation_records)
1126
- ) - set(disk_records)
1127
- for record_id in stale_ids:
1128
- event_service = event_services.get(record_id)
1129
- if event_service is None or not event_service.is_open():
1130
- self._conversation_records.pop(record_id, None)
1131
- for record_id, record in disk_records.items():
1132
- event_service = event_services.get(record_id)
1133
- if event_service is not None and event_service.is_open():
1134
- continue
1135
- existing = self._conversation_records.setdefault(record_id, record)
1136
- if existing.stored != record.stored:
1137
- existing.stored = record.stored
1138
- existing.cached_info = None
1139
1135
  for conversation_id, event_service in event_services.items():
1140
1136
  if conversation_id in self._conversation_records:
1141
1137
  continue
@@ -1270,8 +1266,6 @@ class ConversationService:
1270
1266
  async def get_conversation(self, conversation_id: UUID) -> ConversationInfo | None:
1271
1267
  if self._event_services is None:
1272
1268
  raise ValueError("inactive_service")
1273
- if self.sync_external_catalog:
1274
- await self._reconcile_active_records(conversation_id)
1275
1269
  record = self._conversation_records.get(conversation_id)
1276
1270
  if record is None:
1277
1271
  event_service = self._event_services.get(conversation_id)
@@ -2041,6 +2035,17 @@ class ConversationService:
2041
2035
  async def get_event_service(self, conversation_id: UUID) -> EventService | None:
2042
2036
  return await self._get_or_load_event_service(conversation_id)
2043
2037
 
2038
+ async def get_persisted_event_service(
2039
+ self, conversation_id: UUID
2040
+ ) -> EventService | None:
2041
+ """Open append-only event history without acquiring a runtime lease."""
2042
+ if self._event_services is None:
2043
+ raise ValueError("inactive_service")
2044
+ record = self._conversation_records.get(conversation_id)
2045
+ if record is None:
2046
+ return None
2047
+ return EventService.for_persisted_events(record.stored, self.conversations_dir)
2048
+
2044
2049
  async def generate_conversation_title(
2045
2050
  self, conversation_id: UUID, max_length: int = 50, llm: LLM | None = None
2046
2051
  ) -> str | None:
@@ -2426,7 +2431,6 @@ class ConversationService:
2426
2431
  conversation_idle_ttl_seconds=config.conversation_idle_ttl_seconds,
2427
2432
  conversation_worktree_root=config.conversation_worktree_root,
2428
2433
  acp_skill_sourcing=config.acp_skill_sourcing,
2429
- sync_external_catalog=False,
2430
2434
  )
2431
2435
 
2432
2436
  async def _start_event_service(
@@ -2450,7 +2454,7 @@ class ConversationService:
2450
2454
  stored=stored,
2451
2455
  conversations_dir=self.conversations_dir,
2452
2456
  agent=agent,
2453
- cipher=self.cipher,
2457
+ cipher=self._cipher_for(stored.id),
2454
2458
  mcp_tool_provider=self.mcp_tool_provider,
2455
2459
  credential_bindings=credential_bindings,
2456
2460
  owner_instance_id=self.owner_instance_id,
@@ -5,6 +5,7 @@ from fastapi.security import APIKeyCookie, APIKeyHeader
5
5
 
6
6
  from openhands.agent_server.bash_service import BashEventService
7
7
  from openhands.agent_server.config import Config
8
+ from openhands.agent_server.conversation_registry import ConversationRegistry
8
9
  from openhands.agent_server.conversation_service import ConversationService
9
10
  from openhands.agent_server.event_service import EventService
10
11
 
@@ -90,9 +91,19 @@ async def get_bash_event_service(request: Request) -> BashEventService:
90
91
 
91
92
  async def get_event_service(
92
93
  conversation_id: UUID,
94
+ request: Request,
93
95
  conversation_service: ConversationService = Depends(get_conversation_service),
94
96
  ) -> EventService:
95
- event_service = await conversation_service.get_event_service(conversation_id)
97
+ registry = getattr(request.app.state, "conversation_registry", None)
98
+ if (
99
+ isinstance(registry, ConversationRegistry)
100
+ and registry.serves_persisted_event_reads
101
+ ):
102
+ event_service = await conversation_service.get_persisted_event_service(
103
+ conversation_id
104
+ )
105
+ else:
106
+ event_service = await conversation_service.get_event_service(conversation_id)
96
107
  if event_service is None:
97
108
  raise HTTPException(
98
109
  status_code=status.HTTP_404_NOT_FOUND,
@@ -77,10 +77,14 @@ class RuntimeProvisioningStore:
77
77
  raise ValueError("Runtime mount must not follow a symlink")
78
78
  return resolved
79
79
 
80
- def load(self, conversation_id: UUID) -> RuntimeIdentity:
80
+ def load_optional(self, conversation_id: UUID) -> RuntimeIdentity | None:
81
+ """Load an identity, returning ``None`` only when it does not exist."""
81
82
  path = self.manifest_path(conversation_id)
82
83
  try:
83
84
  manifest_stat = path.lstat()
85
+ except FileNotFoundError:
86
+ self._identities.pop(conversation_id, None)
87
+ return None
84
88
  except OSError:
85
89
  self._identities.pop(conversation_id, None)
86
90
  raise ValueError("Conversation runtime identity is unavailable") from None
@@ -103,6 +107,12 @@ class RuntimeProvisioningStore:
103
107
  self._identities[conversation_id] = (signature, identity)
104
108
  return identity
105
109
 
110
+ def load(self, conversation_id: UUID) -> RuntimeIdentity:
111
+ identity = self.load_optional(conversation_id)
112
+ if identity is None:
113
+ raise ValueError("Conversation runtime identity is unavailable")
114
+ return identity
115
+
106
116
  def create(
107
117
  self, conversation_id: UUID, workspace_path: Path | None = None
108
118
  ) -> RuntimeIdentity:
@@ -133,6 +143,14 @@ class RuntimeProvisioningStore:
133
143
  else:
134
144
  if not workspace_path.is_absolute():
135
145
  raise ValueError("Conversation workspace must be absolute")
146
+ if workspace_path.is_symlink():
147
+ raise ValueError("Conversation workspace must be a real directory")
148
+ try:
149
+ workspace_path.mkdir(parents=True, mode=0o700, exist_ok=True)
150
+ except OSError as exc:
151
+ raise ValueError(
152
+ "Conversation workspace must be a real directory"
153
+ ) from exc
136
154
  if workspace_path.is_symlink() or not workspace_path.is_dir():
137
155
  raise ValueError("Conversation workspace must be a real directory")
138
156
  workspace_path = workspace_path.resolve()
@@ -17,8 +17,13 @@ from uuid import UUID, uuid4
17
17
  from openhands.agent_server.config import V1_SESSION_API_KEY_ENV, Config
18
18
  from openhands.agent_server.conversation_registry import ConversationRegistry
19
19
  from openhands.agent_server.docker_runtime.provisioning import RuntimeProvisioningStore
20
+ from openhands.agent_server.models import (
21
+ ConversationRuntimeInfo,
22
+ ConversationRuntimeStatus,
23
+ )
20
24
  from openhands.agent_server.persistence.store import _get_persistence_dir
21
25
  from openhands.sdk.logger import get_logger
26
+ from openhands.sdk.utils.cipher import Cipher
22
27
  from openhands.sdk.utils.command import execute_command, sanitized_env
23
28
 
24
29
 
@@ -67,14 +72,44 @@ class DockerConversationRegistry(ConversationRegistry):
67
72
  self.provisioning = RuntimeProvisioningStore(config)
68
73
  self._containers: dict[UUID, ConversationContainer] = {}
69
74
  self._starts: dict[UUID, asyncio.Task[ConversationContainer]] = {}
75
+ self._deleting: set[UUID] = set()
70
76
  self._lock = asyncio.Lock()
71
77
 
72
78
  def configure_service(self, service: ConversationService) -> None:
73
- service.sync_external_catalog = True
74
- service.runtime_cipher_resolver = (
75
- lambda conversation_id: self.provisioning.load(conversation_id).cipher
79
+ service.runtime_cipher_resolver = self.resolve_persisted_cipher
80
+
81
+ def resolve_persisted_cipher(self, conversation_id: UUID) -> Cipher:
82
+ """Resolve persisted state without weakening per-runtime isolation.
83
+
84
+ Conversations created before Docker mode have no provisioning identity and
85
+ were encrypted with the host key. A present but invalid identity still
86
+ raises rather than falling back to that key.
87
+ """
88
+ identity = self.provisioning.load_optional(conversation_id)
89
+ return identity.cipher if identity is not None else self.provisioning.cipher
90
+
91
+ def runtime_info(self, conversation_id: UUID) -> ConversationRuntimeInfo:
92
+ identity = self.provisioning.load_optional(conversation_id)
93
+ if identity is None:
94
+ return ConversationRuntimeInfo(
95
+ runtime_status=ConversationRuntimeStatus.MISSING,
96
+ can_resume=False,
97
+ )
98
+ return ConversationRuntimeInfo(
99
+ runtime_status=(
100
+ ConversationRuntimeStatus.AVAILABLE
101
+ if self.get(conversation_id)
102
+ else ConversationRuntimeStatus.STARTING
103
+ if self.is_starting(conversation_id)
104
+ else ConversationRuntimeStatus.MISSING
105
+ ),
106
+ can_resume=True,
76
107
  )
77
108
 
109
+ @property
110
+ def serves_persisted_event_reads(self) -> bool:
111
+ return True
112
+
78
113
  async def start(self) -> None:
79
114
  await asyncio.to_thread(self.cleanup_stale_containers)
80
115
 
@@ -82,7 +117,12 @@ class DockerConversationRegistry(ConversationRegistry):
82
117
  from openhands.agent_server.docker_runtime.routers import (
83
118
  docker_conversation_router,
84
119
  )
120
+ from openhands.agent_server.event_router import event_read_router
85
121
 
122
+ # Persisted event history is safe to read from the outer catalog for
123
+ # both Docker-backed and historical host-local conversations. Writes
124
+ # continue through the runtime proxy below.
125
+ router.include_router(event_read_router)
86
126
  router.include_router(docker_conversation_router)
87
127
 
88
128
  @property
@@ -137,6 +177,8 @@ class DockerConversationRegistry(ConversationRegistry):
137
177
 
138
178
  async def get_or_create(self, conversation_id: UUID) -> ConversationContainer:
139
179
  async with self._lock:
180
+ if conversation_id in self._deleting:
181
+ raise RuntimeError("Conversation is being deleted")
140
182
  container = self._containers.get(conversation_id)
141
183
 
142
184
  if container is not None:
@@ -147,6 +189,8 @@ class DockerConversationRegistry(ConversationRegistry):
147
189
  self._containers.pop(conversation_id)
148
190
 
149
191
  async with self._lock:
192
+ if conversation_id in self._deleting:
193
+ raise RuntimeError("Conversation is being deleted")
150
194
  task = self._starts.get(conversation_id)
151
195
  if task is None:
152
196
  task = asyncio.create_task(
@@ -175,6 +219,17 @@ class DockerConversationRegistry(ConversationRegistry):
175
219
  self._containers[conversation_id] = container
176
220
  return container
177
221
 
222
+ async def begin_delete(self, conversation_id: UUID) -> bool:
223
+ async with self._lock:
224
+ if conversation_id in self._deleting:
225
+ return False
226
+ self._deleting.add(conversation_id)
227
+ return True
228
+
229
+ async def finish_delete(self, conversation_id: UUID) -> None:
230
+ async with self._lock:
231
+ self._deleting.discard(conversation_id)
232
+
178
233
  async def stop(self, conversation_id: UUID) -> None:
179
234
  async with self._lock:
180
235
  task = self._starts.pop(conversation_id, None)
@@ -12,6 +12,7 @@ import httpx
12
12
  from fastapi import APIRouter, HTTPException, Query, Request, WebSocket
13
13
  from starlette.responses import JSONResponse, Response, StreamingResponse
14
14
 
15
+ from openhands.agent_server.dependencies import get_conversation_service
15
16
  from openhands.agent_server.docker_runtime.mediation import (
16
17
  materialize_secrets,
17
18
  prepare_start,
@@ -137,6 +138,10 @@ async def start_conversation(
137
138
  if response.is_error:
138
139
  await registry.stop(conversation_id)
139
140
  content = {"detail": "Conversation runtime rejected the request"}
141
+ else:
142
+ await get_conversation_service(request).refresh_persisted_conversation(
143
+ conversation_id
144
+ )
140
145
  return JSONResponse(content=content, status_code=response.status_code)
141
146
 
142
147
 
@@ -147,18 +152,9 @@ async def runtime_info(
147
152
  conversation_id: UUID, request: Request
148
153
  ) -> ConversationRuntimeInfo:
149
154
  registry = get_registry(request)
150
- if not registry.provisioning.manifest_path(conversation_id).is_file():
155
+ if not registry.conversation_dir(conversation_id).joinpath("meta.json").is_file():
151
156
  raise HTTPException(404, "Conversation not found")
152
- return ConversationRuntimeInfo(
153
- runtime_status=(
154
- ConversationRuntimeStatus.AVAILABLE
155
- if registry.get(conversation_id)
156
- else ConversationRuntimeStatus.STARTING
157
- if registry.is_starting(conversation_id)
158
- else ConversationRuntimeStatus.MISSING
159
- ),
160
- can_resume=True,
161
- )
157
+ return registry.runtime_info(conversation_id)
162
158
 
163
159
 
164
160
  @docker_conversation_router.post("/{conversation_id}/runtime/credentials")
@@ -179,6 +175,9 @@ async def release_runtime(conversation_id: UUID, request: Request) -> Response:
179
175
  raise HTTPException(404, "Conversation not found")
180
176
  try:
181
177
  await registry.stop(conversation_id)
178
+ await get_conversation_service(request).refresh_persisted_conversation(
179
+ conversation_id
180
+ )
182
181
  except Exception as exc:
183
182
  logger.exception("Could not release conversation runtime %s", conversation_id)
184
183
  raise HTTPException(502, "Could not release conversation runtime") from exc
@@ -204,22 +203,31 @@ async def delete_conversation(conversation_id: UUID, request: Request) -> Respon
204
203
  if not registry.provisioning.manifest_path(conversation_id).is_file():
205
204
  raise HTTPException(404, "Conversation not found")
206
205
 
206
+ if not await registry.begin_delete(conversation_id):
207
+ raise HTTPException(409, "Conversation deletion is already in progress")
208
+
207
209
  # Stopping the container closes its conversation service. The outer server
208
210
  # owns the bind-mounted state and removes it after Docker has unmounted it;
209
211
  # asking the inner server to remove the mount root leaves that root in a
210
212
  # partially deleted state.
211
- await registry.stop(conversation_id)
212
- registry.provisioning.manifest_path(conversation_id).unlink(missing_ok=True)
213
- await asyncio.to_thread(
214
- safe_rmtree, registry.provisioning.runtime_dir(conversation_id)
215
- )
216
- await asyncio.to_thread(safe_rmtree, registry.conversation_dir(conversation_id))
213
+ try:
214
+ await registry.stop(conversation_id)
215
+ registry.provisioning.manifest_path(conversation_id).unlink(missing_ok=True)
216
+ await asyncio.to_thread(
217
+ safe_rmtree, registry.provisioning.runtime_dir(conversation_id)
218
+ )
219
+ await asyncio.to_thread(safe_rmtree, registry.conversation_dir(conversation_id))
220
+ await get_conversation_service(request).refresh_persisted_conversation(
221
+ conversation_id
222
+ )
223
+ finally:
224
+ await registry.finish_delete(conversation_id)
217
225
  return Response(status_code=200)
218
226
 
219
227
 
220
228
  @docker_conversation_router.api_route(
221
229
  "/{conversation_id}",
222
- methods=["GET", "POST", "PUT", "PATCH", "OPTIONS", "HEAD"],
230
+ methods=["POST", "PUT", "PATCH", "OPTIONS"],
223
231
  )
224
232
  async def proxy_conversation_root(
225
233
  conversation_id: UUID, request: Request
@@ -243,6 +251,7 @@ async def proxy_conversation(
243
251
  raise HTTPException(501, "This operation is unavailable in Docker runtime mode")
244
252
  registry = get_registry(request)
245
253
  container = await _container(registry, conversation_id)
254
+ upstream_path = _upstream_path(request, request.url.path)
246
255
  if tail == "secrets" and request.method == "POST":
247
256
  try:
248
257
  update = UpdateSecretsRequest.model_validate_json(await request.body())
@@ -260,21 +269,23 @@ async def proxy_conversation(
260
269
  body = UpdateSecretsRequest(secrets=materialized).model_dump(
261
270
  mode="json", context={"expose_secrets": "plaintext"}
262
271
  )
263
- return await proxy_http(
272
+ response = await proxy_http(
264
273
  request,
265
274
  container,
266
- upstream_path=_upstream_path(
267
- request, f"/api/conversations/{conversation_id}/{tail}"
268
- ),
275
+ upstream_path=upstream_path,
269
276
  body=json.dumps(body).encode(),
270
277
  )
271
- return await proxy_http(
272
- request,
273
- container,
274
- upstream_path=_upstream_path(
275
- request, f"/api/conversations/{conversation_id}/{tail}"
276
- ),
277
- )
278
+ else:
279
+ response = await proxy_http(
280
+ request,
281
+ container,
282
+ upstream_path=upstream_path,
283
+ )
284
+ if request.method not in {"GET", "HEAD", "OPTIONS"} and response.status_code < 400:
285
+ await get_conversation_service(request).refresh_persisted_conversation(
286
+ conversation_id
287
+ )
288
+ return response
278
289
 
279
290
 
280
291
  docker_workspace_router = APIRouter(prefix="/conversations", tags=["Docker Workspace"])
@@ -320,11 +331,16 @@ async def _proxy_socket(
320
331
  return
321
332
  query = strip_auth_query("?" + websocket.url.query).lstrip("?")
322
333
  path = f"/sockets/{socket_name}/{conversation_id}"
323
- await bridge_websocket(
324
- websocket,
325
- container,
326
- upstream_path=f"{path}?{query}" if query else path,
327
- )
334
+ try:
335
+ await bridge_websocket(
336
+ websocket,
337
+ container,
338
+ upstream_path=f"{path}?{query}" if query else path,
339
+ )
340
+ finally:
341
+ await websocket.app.state.conversation_service.refresh_persisted_conversation(
342
+ conversation_id
343
+ )
328
344
 
329
345
 
330
346
  @docker_sockets_router.websocket("/events/{conversation_id}")
@@ -27,7 +27,10 @@ from openhands.sdk import Message
27
27
  from openhands.sdk.event import Event
28
28
 
29
29
 
30
- event_router = APIRouter(
30
+ event_read_router = APIRouter(
31
+ prefix="/conversations/{conversation_id}/events", tags=["Events"]
32
+ )
33
+ event_write_router = APIRouter(
31
34
  prefix="/conversations/{conversation_id}/events", tags=["Events"]
32
35
  )
33
36
  logger = logging.getLogger(__name__)
@@ -62,7 +65,9 @@ def normalize_datetime_to_server_timezone(dt: datetime) -> datetime:
62
65
  return dt
63
66
 
64
67
 
65
- @event_router.get("/search", responses={404: {"description": "Conversation not found"}})
68
+ @event_read_router.get(
69
+ "/search", responses={404: {"description": "Conversation not found"}}
70
+ )
66
71
  async def search_conversation_events(
67
72
  page_id: Annotated[
68
73
  str | None,
@@ -134,7 +139,9 @@ async def search_conversation_events(
134
139
  )
135
140
 
136
141
 
137
- @event_router.get("/count", responses={404: {"description": "Conversation not found"}})
142
+ @event_read_router.get(
143
+ "/count", responses={404: {"description": "Conversation not found"}}
144
+ )
138
145
  async def count_conversation_events(
139
146
  kind: Annotated[
140
147
  str | None,
@@ -178,7 +185,9 @@ async def count_conversation_events(
178
185
  return count
179
186
 
180
187
 
181
- @event_router.get("/{event_id}", responses={404: {"description": "Item not found"}})
188
+ @event_read_router.get(
189
+ "/{event_id}", responses={404: {"description": "Item not found"}}
190
+ )
182
191
  async def get_conversation_event(
183
192
  event_id: str,
184
193
  event_service: EventService = Depends(get_event_service),
@@ -190,7 +199,7 @@ async def get_conversation_event(
190
199
  return event
191
200
 
192
201
 
193
- @event_router.get("")
202
+ @event_read_router.get("")
194
203
  async def batch_get_conversation_events(
195
204
  event_ids: list[str],
196
205
  event_service: EventService = Depends(get_event_service),
@@ -201,7 +210,7 @@ async def batch_get_conversation_events(
201
210
  return events
202
211
 
203
212
 
204
- @event_router.post("")
213
+ @event_write_router.post("")
205
214
  async def send_message(
206
215
  request: SendMessageRequest,
207
216
  event_service: EventService = Depends(get_event_service),
@@ -212,7 +221,7 @@ async def send_message(
212
221
  return Success()
213
222
 
214
223
 
215
- @event_router.post(
224
+ @event_write_router.post(
216
225
  "/respond_to_confirmation", responses={404: {"description": "Item not found"}}
217
226
  )
218
227
  async def respond_to_confirmation(
@@ -222,3 +231,8 @@ async def respond_to_confirmation(
222
231
  """Accept or reject a pending action in confirmation mode."""
223
232
  await event_service.respond_to_confirmation(request)
224
233
  return Success()
234
+
235
+
236
+ event_router = APIRouter()
237
+ event_router.include_router(event_read_router)
238
+ event_router.include_router(event_write_router)
@@ -34,6 +34,7 @@ from openhands.sdk.agent.acp_file_credentials import (
34
34
  )
35
35
  from openhands.sdk.agent.stream_context import StreamProgress
36
36
  from openhands.sdk.conversation.base import BaseConversation
37
+ from openhands.sdk.conversation.event_store import EventLog
37
38
  from openhands.sdk.conversation.events_list_base import EventsListBase
38
39
  from openhands.sdk.conversation.exceptions import ConversationRunError
39
40
  from openhands.sdk.conversation.goal import (
@@ -51,7 +52,7 @@ from openhands.sdk.conversation.impl.local_conversation import (
51
52
  ACP_SUPERSEDE_INFLIGHT_PROMPT,
52
53
  LocalConversation,
53
54
  )
54
- from openhands.sdk.conversation.persistence_const import BASE_STATE
55
+ from openhands.sdk.conversation.persistence_const import BASE_STATE, EVENTS_DIR
55
56
  from openhands.sdk.conversation.response_utils import get_agent_final_response
56
57
  from openhands.sdk.conversation.secret_registry import SecretValue
57
58
  from openhands.sdk.conversation.state import (
@@ -75,6 +76,7 @@ from openhands.sdk.event.error_classification import ErrorClassification, Failur
75
76
  from openhands.sdk.event.llm_completion_log import LLMCompletionLogEvent
76
77
  from openhands.sdk.git.exceptions import GitCommandError, GitRepositoryError
77
78
  from openhands.sdk.git.utils import run_git_command, validate_git_repository
79
+ from openhands.sdk.io import LocalFileStore
78
80
  from openhands.sdk.llm.streaming import LLMStreamChunk
79
81
  from openhands.sdk.mcp.utils import MCPToolProvider
80
82
  from openhands.sdk.security.analyzer import SecurityAnalyzerBase
@@ -135,6 +137,7 @@ class EventService:
135
137
  owner_instance_id: str = field(default_factory=lambda: uuid4().hex)
136
138
  lease_ttl_seconds: float = DEFAULT_LEASE_TTL_SECONDS
137
139
  _conversation: LocalConversation | None = field(default=None, init=False)
140
+ _persisted_events: EventLog | None = field(default=None, init=False)
138
141
  _pub_sub: PubSub[Event] = field(
139
142
  default_factory=lambda: PubSub[Event](max_subscribers=50), init=False
140
143
  )
@@ -407,6 +410,25 @@ class EventService:
407
410
  raise ValueError("inactive_service")
408
411
  return self._conversation
409
412
 
413
+ @classmethod
414
+ def for_persisted_events(
415
+ cls, stored: StoredConversation, conversations_dir: Path
416
+ ) -> "EventService":
417
+ """Open append-only history without starting a conversation runtime."""
418
+ service = cls(stored=stored, conversations_dir=conversations_dir)
419
+ conversation_dir = conversations_dir / stored.id.hex
420
+ service._persisted_events = EventLog(
421
+ LocalFileStore(str(conversation_dir)), dir_path=EVENTS_DIR
422
+ )
423
+ return service
424
+
425
+ def _events_for_read(self) -> EventLog:
426
+ if self._persisted_events is not None:
427
+ return self._persisted_events
428
+ if self._conversation is None:
429
+ raise ValueError("inactive_service")
430
+ return self._conversation._state.events
431
+
410
432
  def _get_event_sync(self, event_id: str) -> Event | None:
411
433
  """Private sync function to get a single event.
412
434
 
@@ -414,15 +436,11 @@ class EventService:
414
436
  EventLog reads are safe without the FIFOLock because events are
415
437
  append-only and immutable once written.
416
438
  """
417
- if not self._conversation:
418
- raise ValueError("inactive_service")
419
- events = self._conversation._state.events
439
+ events = self._events_for_read()
420
440
  index = events.get_index(event_id)
421
441
  return events[index]
422
442
 
423
443
  async def get_event(self, event_id: str) -> Event | None:
424
- if not self._conversation:
425
- raise ValueError("inactive_service")
426
444
  loop = asyncio.get_running_loop()
427
445
  return await loop.run_in_executor(None, self._get_event_sync, event_id)
428
446
 
@@ -494,10 +512,7 @@ class EventService:
494
512
  difference between "loads instantly" and "blocks for seconds"
495
513
  for long conversations.
496
514
  """
497
- if not self._conversation:
498
- raise ValueError("inactive_service")
499
-
500
- events = self._conversation._state.events
515
+ events = self._events_for_read()
501
516
  total = len(events)
502
517
 
503
518
  # Convert datetime to ISO string for comparison (ISO strings are comparable)
@@ -560,8 +575,6 @@ class EventService:
560
575
  timestamp__gte: datetime | None = None,
561
576
  timestamp__lt: datetime | None = None,
562
577
  ) -> EventPage:
563
- if not self._conversation:
564
- raise ValueError("inactive_service")
565
578
  loop = asyncio.get_running_loop()
566
579
  return await loop.run_in_executor(
567
580
  None,
@@ -590,10 +603,7 @@ class EventService:
590
603
  EventLog reads are safe without the FIFOLock because events are
591
604
  append-only and immutable once written.
592
605
  """
593
- if not self._conversation:
594
- raise ValueError("inactive_service")
595
-
596
- events = self._conversation._state.events
606
+ events = self._events_for_read()
597
607
 
598
608
  # Fast path: with no filters, the count is just the sequence length
599
609
  # and we can avoid reading any event payloads from disk.
@@ -630,8 +640,6 @@ class EventService:
630
640
  timestamp__lt: datetime | None = None,
631
641
  ) -> int:
632
642
  """Count events matching the given filters."""
633
- if not self._conversation:
634
- raise ValueError("inactive_service")
635
643
  loop = asyncio.get_running_loop()
636
644
  return await loop.run_in_executor(
637
645
  None,
@@ -360,6 +360,13 @@ class ConversationInfo(_ConversationInfoBase):
360
360
  "persisted events, avoiding 'Unknown kind' deserialization errors."
361
361
  ),
362
362
  )
363
+ runtime_info: ConversationRuntimeInfo | None = Field(
364
+ default=None,
365
+ description=(
366
+ "Availability of the execution runtime. Catalog responses populate "
367
+ "this when the hosting server manages runtime lifecycle."
368
+ ),
369
+ )
363
370
 
364
371
 
365
372
  class ConversationPage(BaseModel):
@@ -33,8 +33,10 @@ async def bind_local_conversation_runtime(
33
33
  ) -> None:
34
34
  conversation_service = get_conversation_service(request)
35
35
  event_service = await get_event_service(
36
- runtime_conversation_id, conversation_service
36
+ runtime_conversation_id, request, conversation_service
37
37
  )
38
+ if event_service is None:
39
+ raise HTTPException(404, "Conversation not found")
38
40
  request.state.runtime_event_service = event_service
39
41
  workspace_root = Path(
40
42
  event_service.get_conversation().workspace.working_dir
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openhands-agent-server
3
- Version: 1.49.0
3
+ Version: 1.49.2
4
4
  Summary: OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent
5
5
  Project-URL: Source, https://github.com/OpenHands/software-agent-sdk
6
6
  Project-URL: Homepage, https://github.com/OpenHands/software-agent-sdk
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "openhands-agent-server"
3
- version = "1.49.0"
3
+ version = "1.49.2"
4
4
  description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
5
5
 
6
6
  requires-python = ">=3.12"