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.
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/PKG-INFO +1 -1
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/conversation_registry.py +17 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/conversation_router.py +32 -4
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/conversation_service.py +34 -30
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/dependencies.py +12 -1
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/docker_runtime/provisioning.py +19 -1
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/docker_runtime/registry.py +58 -3
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/docker_runtime/routers.py +50 -34
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/event_router.py +21 -7
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/event_service.py +26 -18
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/models.py +7 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/runtime_router.py +3 -1
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands_agent_server.egg-info/PKG-INFO +1 -1
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/pyproject.toml +1 -1
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/__init__.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/__main__.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/_secret_redaction.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/_secrets_exposure.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/agent_profiles_router.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/api.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/auth_router.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/bash_router.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/bash_service.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/canvas_extensions/__init__.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/canvas_extensions/installed.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/canvas_extensions/manifest.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/canvas_extensions_router.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/config.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/conversation_lease.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/credential_binding.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/docker/Dockerfile +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/docker/build.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/docker_runtime/__init__.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/docker_runtime/mediation.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/docker_runtime/proxy.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/env_parser.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/file_router.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/git_router.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/hooks_router.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/hooks_service.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/init_router.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/llm_router.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/logging_config.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/mcp_oauth_store.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/mcp_router.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/middleware.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/openai/__init__.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/openai/models.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/openai/router.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/openai/service.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/openapi.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/persistence/__init__.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/persistence/models.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/persistence/store.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/plugins_router.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/plugins_service.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/profiles_router.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/provider_connections_router.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/pub_sub.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/py.typed +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/server_details_router.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/session_protocol.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/session_socket.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/settings_router.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/skills_router.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/skills_service.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/sockets.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/sub_agents_router.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/telemetry/__init__.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/telemetry/factory.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/telemetry/http_exporter.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/telemetry/models.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/telemetry/policy.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/telemetry/posthog_exporter.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/telemetry/sanitizer.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/telemetry/service.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/telemetry/sink.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/telemetry/subscriber.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/telemetry_types.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/tool_preload_service.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/tool_router.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/utils.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/vscode_extensions/openhands-settings/extension.js +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/vscode_extensions/openhands-settings/package.json +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/vscode_router.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/vscode_service.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/workspace_router.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/workspaces_router.py +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands_agent_server.egg-info/SOURCES.txt +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands_agent_server.egg-info/dependency_links.txt +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands_agent_server.egg-info/entry_points.txt +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands_agent_server.egg-info/requires.txt +0 -0
- {openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands_agent_server.egg-info/top_level.txt +0 -0
- {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.
|
|
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(
|
|
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
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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.
|
|
74
|
-
|
|
75
|
-
|
|
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.
|
|
155
|
+
if not registry.conversation_dir(conversation_id).joinpath("meta.json").is_file():
|
|
151
156
|
raise HTTPException(404, "Conversation not found")
|
|
152
|
-
return
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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=["
|
|
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
|
-
|
|
272
|
+
response = await proxy_http(
|
|
264
273
|
request,
|
|
265
274
|
container,
|
|
266
|
-
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
{openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/models.py
RENAMED
|
@@ -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.
|
|
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
|
{openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/__init__.py
RENAMED
|
File without changes
|
{openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/__main__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/api.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/config.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/env_parser.py
RENAMED
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/git_router.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/llm_router.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/mcp_router.py
RENAMED
|
File without changes
|
{openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/middleware.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/openapi.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/pub_sub.py
RENAMED
|
File without changes
|
{openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/py.typed
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/sockets.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.49.0 → openhands_agent_server-1.49.2}/openhands/agent_server/utils.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|