openhands-agent-server 1.22.1__tar.gz → 1.23.0__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.22.1 → openhands_agent_server-1.23.0}/PKG-INFO +1 -1
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/api.py +4 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/conversation_router.py +52 -1
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/conversation_router_acp.py +27 -2
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/conversation_service.py +19 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/event_service.py +57 -3
- openhands_agent_server-1.23.0/openhands/agent_server/mcp_router.py +225 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/models.py +54 -3
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/persistence/__init__.py +14 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/persistence/models.py +49 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/persistence/store.py +107 -1
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/server_details_router.py +10 -0
- openhands_agent_server-1.23.0/openhands/agent_server/skills_router.py +530 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/skills_service.py +298 -0
- openhands_agent_server-1.23.0/openhands/agent_server/workspaces_router.py +244 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands_agent_server.egg-info/PKG-INFO +1 -1
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands_agent_server.egg-info/SOURCES.txt +4 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/pyproject.toml +1 -1
- openhands_agent_server-1.22.1/openhands/agent_server/skills_router.py +0 -192
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/__init__.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/__main__.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/_secrets_exposure.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/auth_router.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/bash_router.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/bash_service.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/cloud_proxy_router.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/config.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/conversation_lease.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/dependencies.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/desktop_router.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/desktop_service.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/docker/Dockerfile +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/docker/build.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/docker/wallpaper.svg +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/env_parser.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/event_router.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/file_router.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/git_router.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/hooks_router.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/hooks_service.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/llm_router.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/logging_config.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/middleware.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/openapi.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/profiles_router.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/pub_sub.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/py.typed +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/settings_router.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/sockets.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/tool_preload_service.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/tool_router.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/utils.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/vscode_extensions/openhands-settings/extension.js +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/vscode_extensions/openhands-settings/package.json +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/vscode_router.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/vscode_service.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/workspace_router.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands_agent_server.egg-info/dependency_links.txt +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands_agent_server.egg-info/entry_points.txt +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands_agent_server.egg-info/requires.txt +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/openhands_agent_server.egg-info/top_level.txt +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openhands-agent-server
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.23.0
|
|
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.22.1 → openhands_agent_server-1.23.0}/openhands/agent_server/api.py
RENAMED
|
@@ -38,6 +38,7 @@ from openhands.agent_server.file_router import file_router
|
|
|
38
38
|
from openhands.agent_server.git_router import git_router
|
|
39
39
|
from openhands.agent_server.hooks_router import hooks_router
|
|
40
40
|
from openhands.agent_server.llm_router import llm_router
|
|
41
|
+
from openhands.agent_server.mcp_router import mcp_router
|
|
41
42
|
from openhands.agent_server.middleware import LocalhostCORSMiddleware
|
|
42
43
|
from openhands.agent_server.profiles_router import profiles_router
|
|
43
44
|
from openhands.agent_server.server_details_router import (
|
|
@@ -53,6 +54,7 @@ from openhands.agent_server.tool_router import tool_router
|
|
|
53
54
|
from openhands.agent_server.vscode_router import vscode_router
|
|
54
55
|
from openhands.agent_server.vscode_service import get_vscode_service
|
|
55
56
|
from openhands.agent_server.workspace_router import workspace_router
|
|
57
|
+
from openhands.agent_server.workspaces_router import workspaces_router
|
|
56
58
|
from openhands.sdk.logger import DEBUG, get_logger
|
|
57
59
|
from openhands.sdk.utils.redact import sanitize_dict
|
|
58
60
|
from openhands.tools.terminal.constants import TMUX_SOCKET_NAME
|
|
@@ -288,7 +290,9 @@ def _add_api_routes(app: FastAPI, config: Config) -> None:
|
|
|
288
290
|
api_router.include_router(skills_router)
|
|
289
291
|
api_router.include_router(hooks_router)
|
|
290
292
|
api_router.include_router(llm_router)
|
|
293
|
+
api_router.include_router(mcp_router)
|
|
291
294
|
api_router.include_router(settings_router)
|
|
295
|
+
api_router.include_router(workspaces_router)
|
|
292
296
|
api_router.include_router(profiles_router)
|
|
293
297
|
api_router.include_router(cloud_proxy_router)
|
|
294
298
|
# /api/auth/* mints workspace cookies and requires the header to bootstrap,
|
|
@@ -22,6 +22,7 @@ from openhands.agent_server._secrets_exposure import (
|
|
|
22
22
|
from openhands.agent_server.conversation_service import ConversationService
|
|
23
23
|
from openhands.agent_server.dependencies import get_conversation_service
|
|
24
24
|
from openhands.agent_server.models import (
|
|
25
|
+
INCLUDE_SKILLS_PARAM_TITLE,
|
|
25
26
|
AgentResponseResult,
|
|
26
27
|
AskAgentRequest,
|
|
27
28
|
AskAgentResponse,
|
|
@@ -36,6 +37,7 @@ from openhands.agent_server.models import (
|
|
|
36
37
|
Success,
|
|
37
38
|
UpdateConversationRequest,
|
|
38
39
|
UpdateSecretsRequest,
|
|
40
|
+
trim_conversation_response_skills,
|
|
39
41
|
)
|
|
40
42
|
from openhands.sdk import LLM, Agent, TextContent
|
|
41
43
|
from openhands.sdk.conversation.state import ConversationExecutionStatus
|
|
@@ -86,14 +88,28 @@ async def search_conversations(
|
|
|
86
88
|
ConversationSortOrder,
|
|
87
89
|
Query(title="Sort order for conversations"),
|
|
88
90
|
] = ConversationSortOrder.CREATED_AT_DESC,
|
|
91
|
+
include_skills: Annotated[bool, Query(title=INCLUDE_SKILLS_PARAM_TITLE)] = False,
|
|
89
92
|
conversation_service: ConversationService = Depends(get_conversation_service),
|
|
90
93
|
) -> ConversationPage:
|
|
91
94
|
"""Search / List conversations"""
|
|
92
95
|
assert limit > 0
|
|
93
96
|
assert limit <= 100
|
|
94
|
-
|
|
97
|
+
page = await conversation_service.search_conversations(
|
|
95
98
|
page_id, limit, status, sort_order
|
|
96
99
|
)
|
|
100
|
+
if not include_skills:
|
|
101
|
+
# ``model_copy`` rather than in-place mutation so we never
|
|
102
|
+
# write back into whatever the upstream service handed us
|
|
103
|
+
# (matters for services that cache their return value,
|
|
104
|
+
# including the ``AsyncMock`` used in route tests).
|
|
105
|
+
page = page.model_copy(
|
|
106
|
+
update={
|
|
107
|
+
"items": [
|
|
108
|
+
trim_conversation_response_skills(item) for item in page.items
|
|
109
|
+
]
|
|
110
|
+
}
|
|
111
|
+
)
|
|
112
|
+
return page
|
|
97
113
|
|
|
98
114
|
|
|
99
115
|
@conversation_router.get("/count")
|
|
@@ -114,12 +130,15 @@ async def count_conversations(
|
|
|
114
130
|
)
|
|
115
131
|
async def get_conversation(
|
|
116
132
|
conversation_id: UUID,
|
|
133
|
+
include_skills: Annotated[bool, Query(title=INCLUDE_SKILLS_PARAM_TITLE)] = False,
|
|
117
134
|
conversation_service: ConversationService = Depends(get_conversation_service),
|
|
118
135
|
) -> ConversationInfo:
|
|
119
136
|
"""Given an id, get a conversation"""
|
|
120
137
|
conversation = await conversation_service.get_conversation(conversation_id)
|
|
121
138
|
if conversation is None:
|
|
122
139
|
raise HTTPException(status.HTTP_404_NOT_FOUND)
|
|
140
|
+
if not include_skills:
|
|
141
|
+
conversation = trim_conversation_response_skills(conversation)
|
|
123
142
|
return conversation
|
|
124
143
|
|
|
125
144
|
|
|
@@ -147,12 +166,18 @@ async def get_conversation_agent_final_response(
|
|
|
147
166
|
@conversation_router.get("")
|
|
148
167
|
async def batch_get_conversations(
|
|
149
168
|
ids: Annotated[list[UUID], Query()],
|
|
169
|
+
include_skills: Annotated[bool, Query(title=INCLUDE_SKILLS_PARAM_TITLE)] = False,
|
|
150
170
|
conversation_service: ConversationService = Depends(get_conversation_service),
|
|
151
171
|
) -> list[ConversationInfo | None]:
|
|
152
172
|
"""Get a batch of conversations given their ids, returning null for
|
|
153
173
|
any missing item"""
|
|
154
174
|
assert len(ids) < 100
|
|
155
175
|
conversations = await conversation_service.batch_get_conversations(ids)
|
|
176
|
+
if not include_skills:
|
|
177
|
+
return [
|
|
178
|
+
trim_conversation_response_skills(c) if c is not None else None
|
|
179
|
+
for c in conversations
|
|
180
|
+
]
|
|
156
181
|
return conversations
|
|
157
182
|
|
|
158
183
|
|
|
@@ -165,11 +190,14 @@ async def start_conversation(
|
|
|
165
190
|
StartConversationRequest, Body(examples=START_CONVERSATION_EXAMPLES)
|
|
166
191
|
],
|
|
167
192
|
response: Response,
|
|
193
|
+
include_skills: Annotated[bool, Query(title=INCLUDE_SKILLS_PARAM_TITLE)] = False,
|
|
168
194
|
conversation_service: ConversationService = Depends(get_conversation_service),
|
|
169
195
|
) -> ConversationInfo:
|
|
170
196
|
"""Start a conversation in the local environment."""
|
|
171
197
|
info, is_new = await conversation_service.start_conversation(request)
|
|
172
198
|
response.status_code = status.HTTP_201_CREATED if is_new else status.HTTP_200_OK
|
|
199
|
+
if not include_skills:
|
|
200
|
+
info = trim_conversation_response_skills(info)
|
|
173
201
|
return info
|
|
174
202
|
|
|
175
203
|
|
|
@@ -187,6 +215,26 @@ async def pause_conversation(
|
|
|
187
215
|
return Success()
|
|
188
216
|
|
|
189
217
|
|
|
218
|
+
@conversation_router.post(
|
|
219
|
+
"/{conversation_id}/interrupt",
|
|
220
|
+
responses={404: {"description": "Item not found"}},
|
|
221
|
+
)
|
|
222
|
+
async def interrupt_conversation(
|
|
223
|
+
conversation_id: UUID,
|
|
224
|
+
conversation_service: ConversationService = Depends(get_conversation_service),
|
|
225
|
+
) -> Success:
|
|
226
|
+
"""Immediately interrupt a running conversation.
|
|
227
|
+
|
|
228
|
+
Unlike ``/pause``, which waits for the current LLM call to finish,
|
|
229
|
+
``/interrupt`` cancels the in-flight request so the effect is instant.
|
|
230
|
+
The conversation transitions to *paused* and can be resumed later.
|
|
231
|
+
"""
|
|
232
|
+
interrupted = await conversation_service.interrupt_conversation(conversation_id)
|
|
233
|
+
if not interrupted:
|
|
234
|
+
raise HTTPException(status.HTTP_400_BAD_REQUEST)
|
|
235
|
+
return Success()
|
|
236
|
+
|
|
237
|
+
|
|
190
238
|
@conversation_router.delete(
|
|
191
239
|
"/{conversation_id}", responses={404: {"description": "Item not found"}}
|
|
192
240
|
)
|
|
@@ -407,6 +455,7 @@ async def condense_conversation(
|
|
|
407
455
|
async def fork_conversation(
|
|
408
456
|
conversation_id: UUID,
|
|
409
457
|
request: Annotated[ForkConversationRequest, Body()] = ForkConversationRequest(), # noqa: B008
|
|
458
|
+
include_skills: Annotated[bool, Query(title=INCLUDE_SKILLS_PARAM_TITLE)] = False,
|
|
410
459
|
conversation_service: ConversationService = Depends(get_conversation_service),
|
|
411
460
|
) -> ConversationInfo:
|
|
412
461
|
"""Fork a conversation, deep-copying its event history.
|
|
@@ -432,4 +481,6 @@ async def fork_conversation(
|
|
|
432
481
|
status.HTTP_404_NOT_FOUND,
|
|
433
482
|
detail="Source conversation not found",
|
|
434
483
|
)
|
|
484
|
+
if not include_skills:
|
|
485
|
+
info = trim_conversation_response_skills(info)
|
|
435
486
|
return info
|
|
@@ -14,11 +14,13 @@ from pydantic import SecretStr
|
|
|
14
14
|
from openhands.agent_server.conversation_service import ConversationService
|
|
15
15
|
from openhands.agent_server.dependencies import get_conversation_service
|
|
16
16
|
from openhands.agent_server.models import (
|
|
17
|
+
INCLUDE_SKILLS_PARAM_TITLE,
|
|
17
18
|
ACPConversationInfo,
|
|
18
19
|
ACPConversationPage,
|
|
19
20
|
ConversationSortOrder,
|
|
20
21
|
SendMessageRequest,
|
|
21
22
|
StartACPConversationRequest,
|
|
23
|
+
trim_conversation_response_skills,
|
|
22
24
|
)
|
|
23
25
|
from openhands.sdk import LLM, Agent, TextContent
|
|
24
26
|
from openhands.sdk.agent.acp_agent import ACPAgent
|
|
@@ -76,6 +78,7 @@ async def search_acp_conversations(
|
|
|
76
78
|
ConversationSortOrder,
|
|
77
79
|
Query(title="Sort order for conversations"),
|
|
78
80
|
] = ConversationSortOrder.CREATED_AT_DESC,
|
|
81
|
+
include_skills: Annotated[bool, Query(title=INCLUDE_SKILLS_PARAM_TITLE)] = False,
|
|
79
82
|
conversation_service: ConversationService = Depends(get_conversation_service),
|
|
80
83
|
) -> ACPConversationPage:
|
|
81
84
|
"""Search conversations using the ACP-capable contract.
|
|
@@ -85,9 +88,18 @@ async def search_acp_conversations(
|
|
|
85
88
|
"""
|
|
86
89
|
assert limit > 0
|
|
87
90
|
assert limit <= 100
|
|
88
|
-
|
|
91
|
+
page = await conversation_service.search_acp_conversations(
|
|
89
92
|
page_id, limit, status, sort_order
|
|
90
93
|
)
|
|
94
|
+
if not include_skills:
|
|
95
|
+
page = page.model_copy(
|
|
96
|
+
update={
|
|
97
|
+
"items": [
|
|
98
|
+
trim_conversation_response_skills(item) for item in page.items
|
|
99
|
+
]
|
|
100
|
+
}
|
|
101
|
+
)
|
|
102
|
+
return page
|
|
91
103
|
|
|
92
104
|
|
|
93
105
|
@conversation_router_acp.get("/count", deprecated=True)
|
|
@@ -113,6 +125,7 @@ async def count_acp_conversations(
|
|
|
113
125
|
)
|
|
114
126
|
async def get_acp_conversation(
|
|
115
127
|
conversation_id: UUID,
|
|
128
|
+
include_skills: Annotated[bool, Query(title=INCLUDE_SKILLS_PARAM_TITLE)] = False,
|
|
116
129
|
conversation_service: ConversationService = Depends(get_conversation_service),
|
|
117
130
|
) -> ACPConversationInfo:
|
|
118
131
|
"""Get a conversation using the ACP-capable contract.
|
|
@@ -123,12 +136,15 @@ async def get_acp_conversation(
|
|
|
123
136
|
conversation = await conversation_service.get_acp_conversation(conversation_id)
|
|
124
137
|
if conversation is None:
|
|
125
138
|
raise HTTPException(status.HTTP_404_NOT_FOUND)
|
|
139
|
+
if not include_skills:
|
|
140
|
+
conversation = trim_conversation_response_skills(conversation)
|
|
126
141
|
return conversation
|
|
127
142
|
|
|
128
143
|
|
|
129
144
|
@conversation_router_acp.get("", deprecated=True)
|
|
130
145
|
async def batch_get_acp_conversations(
|
|
131
146
|
ids: Annotated[list[UUID], Query()],
|
|
147
|
+
include_skills: Annotated[bool, Query(title=INCLUDE_SKILLS_PARAM_TITLE)] = False,
|
|
132
148
|
conversation_service: ConversationService = Depends(get_conversation_service),
|
|
133
149
|
) -> list[ACPConversationInfo | None]:
|
|
134
150
|
"""Batch get conversations using the ACP-capable contract.
|
|
@@ -137,7 +153,13 @@ async def batch_get_acp_conversations(
|
|
|
137
153
|
Use ``/api/conversations`` instead.
|
|
138
154
|
"""
|
|
139
155
|
assert len(ids) < 100
|
|
140
|
-
|
|
156
|
+
conversations = await conversation_service.batch_get_acp_conversations(ids)
|
|
157
|
+
if not include_skills:
|
|
158
|
+
return [
|
|
159
|
+
trim_conversation_response_skills(c) if c is not None else None
|
|
160
|
+
for c in conversations
|
|
161
|
+
]
|
|
162
|
+
return conversations
|
|
141
163
|
|
|
142
164
|
|
|
143
165
|
@conversation_router_acp.post("", deprecated=True)
|
|
@@ -147,6 +169,7 @@ async def start_acp_conversation(
|
|
|
147
169
|
Body(examples=START_ACP_CONVERSATION_EXAMPLES),
|
|
148
170
|
],
|
|
149
171
|
response: Response,
|
|
172
|
+
include_skills: Annotated[bool, Query(title=INCLUDE_SKILLS_PARAM_TITLE)] = False,
|
|
150
173
|
conversation_service: ConversationService = Depends(get_conversation_service),
|
|
151
174
|
) -> ACPConversationInfo:
|
|
152
175
|
"""Start a conversation using the ACP-capable contract.
|
|
@@ -157,4 +180,6 @@ async def start_acp_conversation(
|
|
|
157
180
|
"""
|
|
158
181
|
info, is_new = await conversation_service.start_acp_conversation(request)
|
|
159
182
|
response.status_code = status.HTTP_201_CREATED if is_new else status.HTTP_200_OK
|
|
183
|
+
if not include_skills:
|
|
184
|
+
info = trim_conversation_response_skills(info)
|
|
160
185
|
return info
|
|
@@ -649,6 +649,25 @@ class ConversationService:
|
|
|
649
649
|
await self._notify_conversation_webhooks(conversation_info)
|
|
650
650
|
return bool(event_service)
|
|
651
651
|
|
|
652
|
+
async def interrupt_conversation(self, conversation_id: UUID) -> bool:
|
|
653
|
+
"""Immediately cancel an in-flight LLM call for a conversation.
|
|
654
|
+
|
|
655
|
+
Unlike :meth:`pause_conversation`, which waits for the current
|
|
656
|
+
LLM request to finish, this cancels the running ``arun()`` task
|
|
657
|
+
so the interruption takes effect mid-stream.
|
|
658
|
+
"""
|
|
659
|
+
if self._event_services is None:
|
|
660
|
+
raise ValueError("inactive_service")
|
|
661
|
+
event_service = self._event_services.get(conversation_id)
|
|
662
|
+
if event_service:
|
|
663
|
+
await event_service.interrupt()
|
|
664
|
+
state = await event_service.get_state()
|
|
665
|
+
conversation_info = _compose_webhook_conversation_info(
|
|
666
|
+
event_service.stored, state
|
|
667
|
+
)
|
|
668
|
+
await self._notify_conversation_webhooks(conversation_info)
|
|
669
|
+
return bool(event_service)
|
|
670
|
+
|
|
652
671
|
async def resume_conversation(self, conversation_id: UUID) -> bool:
|
|
653
672
|
if self._event_services is None:
|
|
654
673
|
raise ValueError("inactive_service")
|
|
@@ -18,6 +18,7 @@ from openhands.agent_server.models import (
|
|
|
18
18
|
)
|
|
19
19
|
from openhands.agent_server.pub_sub import PubSub, Subscriber
|
|
20
20
|
from openhands.sdk import LLM, AgentBase, Event, Message, get_logger
|
|
21
|
+
from openhands.sdk.conversation.base import BaseConversation
|
|
21
22
|
from openhands.sdk.conversation.impl.local_conversation import LocalConversation
|
|
22
23
|
from openhands.sdk.conversation.response_utils import get_agent_final_response
|
|
23
24
|
from openhands.sdk.conversation.secret_registry import SecretValue
|
|
@@ -706,8 +707,11 @@ class EventService:
|
|
|
706
707
|
"""Run the conversation asynchronously in the background.
|
|
707
708
|
|
|
708
709
|
This method starts the conversation run in a background task and returns
|
|
709
|
-
immediately.
|
|
710
|
-
|
|
710
|
+
immediately. When possible, the conversation is driven via its native
|
|
711
|
+
``arun()`` coroutine so LLM I/O does not tie up a thread-pool worker.
|
|
712
|
+
For conversations that do not expose ``arun()`` (e.g., custom
|
|
713
|
+
subclasses), the synchronous ``run()`` is executed in the thread pool as
|
|
714
|
+
before.
|
|
711
715
|
|
|
712
716
|
Raises:
|
|
713
717
|
ValueError: If the service is inactive or conversation is already running.
|
|
@@ -735,7 +739,28 @@ class EventService:
|
|
|
735
739
|
|
|
736
740
|
async def _run_and_publish():
|
|
737
741
|
try:
|
|
738
|
-
|
|
742
|
+
# Prefer the native async path when available so the event
|
|
743
|
+
# loop is free during LLM I/O. Fall back to thread-pool
|
|
744
|
+
# execution for backward compatibility.
|
|
745
|
+
#
|
|
746
|
+
# Both guards are required:
|
|
747
|
+
# • iscoroutinefunction – filters out non-async objects
|
|
748
|
+
# (e.g. MagicMock in tests).
|
|
749
|
+
# • override check – BaseConversation defines a default
|
|
750
|
+
# ``async def arun()`` that delegates to sync ``run()``,
|
|
751
|
+
# so iscoroutinefunction alone is always True for real
|
|
752
|
+
# subclasses. We detect an *actual* override to avoid
|
|
753
|
+
# running a sync-only subclass on the event loop.
|
|
754
|
+
arun = getattr(conversation, "arun", None)
|
|
755
|
+
has_native_arun = (
|
|
756
|
+
arun is not None
|
|
757
|
+
and asyncio.iscoroutinefunction(arun)
|
|
758
|
+
and type(conversation).arun is not BaseConversation.arun
|
|
759
|
+
)
|
|
760
|
+
if has_native_arun:
|
|
761
|
+
await conversation.arun()
|
|
762
|
+
else:
|
|
763
|
+
await loop.run_in_executor(self._run_executor, conversation.run)
|
|
739
764
|
except Exception:
|
|
740
765
|
logger.exception("Error during conversation run")
|
|
741
766
|
finally:
|
|
@@ -787,6 +812,28 @@ class EventService:
|
|
|
787
812
|
# Publish state update after pause to ensure stats are updated
|
|
788
813
|
await self._publish_state_update()
|
|
789
814
|
|
|
815
|
+
async def interrupt(self):
|
|
816
|
+
"""Immediately cancel an in-flight async LLM call.
|
|
817
|
+
|
|
818
|
+
Delegates to :meth:`LocalConversation.interrupt` which cancels the
|
|
819
|
+
``arun()`` task. If no async run is in progress the call falls
|
|
820
|
+
back to :meth:`pause`.
|
|
821
|
+
"""
|
|
822
|
+
if self._conversation:
|
|
823
|
+
self._conversation.interrupt()
|
|
824
|
+
# Wait for the run task to finish so we can publish the final
|
|
825
|
+
# state update (PAUSED + InterruptEvent) cleanly.
|
|
826
|
+
if self._run_task is not None and not self._run_task.done():
|
|
827
|
+
with suppress(Exception):
|
|
828
|
+
await asyncio.wait_for(self._run_task, timeout=5.0)
|
|
829
|
+
# Only clear _run_task if it actually finished; if
|
|
830
|
+
# wait_for timed out the task may still be running and
|
|
831
|
+
# clearing prematurely would allow a second run() to
|
|
832
|
+
# start while the first is still in progress.
|
|
833
|
+
if self._run_task is not None and self._run_task.done():
|
|
834
|
+
self._run_task = None
|
|
835
|
+
await self._publish_state_update()
|
|
836
|
+
|
|
790
837
|
async def update_secrets(self, secrets: dict[str, SecretValue]):
|
|
791
838
|
"""Update secrets in the conversation."""
|
|
792
839
|
if not self._conversation:
|
|
@@ -832,8 +879,15 @@ class EventService:
|
|
|
832
879
|
logger.warning(
|
|
833
880
|
"Failed to pause conversation during close", exc_info=True
|
|
834
881
|
)
|
|
882
|
+
# Cancel the run task so arun()'s CancelledError handler can
|
|
883
|
+
# transition to PAUSED cleanly. For the legacy thread-pool
|
|
884
|
+
# path the underlying thread keeps running but the wrapper
|
|
885
|
+
# task still settles, unblocking the wait below.
|
|
886
|
+
self._run_task.cancel()
|
|
835
887
|
try:
|
|
836
888
|
await asyncio.wait_for(self._run_task, timeout=10.0)
|
|
889
|
+
except asyncio.CancelledError:
|
|
890
|
+
pass # Expected after cancel()
|
|
837
891
|
except Exception as exc:
|
|
838
892
|
logger.warning("Run task did not exit cleanly during close: %s", exc)
|
|
839
893
|
self._run_task = None
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""MCP router for OpenHands SDK.
|
|
2
|
+
|
|
3
|
+
Exposes a single endpoint, ``POST /api/mcp/test``, that lets clients verify
|
|
4
|
+
a candidate MCP server configuration in isolation -- before persisting it
|
|
5
|
+
to settings, where a misconfiguration would otherwise surface only at
|
|
6
|
+
conversation start (and there manifest as a noisy traceback that aborts
|
|
7
|
+
agent initialization).
|
|
8
|
+
|
|
9
|
+
The endpoint is intentionally side-effect-free: it spins up the MCP
|
|
10
|
+
connection, lists the advertised tools, then tears the connection down.
|
|
11
|
+
It never mutates server state or touches stored settings.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
from typing import Annotated, Any, Literal
|
|
18
|
+
|
|
19
|
+
from fastapi import APIRouter
|
|
20
|
+
from pydantic import BaseModel, Field, model_validator
|
|
21
|
+
|
|
22
|
+
from openhands.sdk.logger import get_logger
|
|
23
|
+
from openhands.sdk.mcp import create_mcp_tools
|
|
24
|
+
from openhands.sdk.mcp.exceptions import MCPError, MCPTimeoutError
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
logger = get_logger(__name__)
|
|
28
|
+
|
|
29
|
+
mcp_router = APIRouter(prefix="/mcp", tags=["MCP"])
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Request / response models
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
#
|
|
36
|
+
# We accept a single server spec instead of the full ``MCPConfig`` map. The
|
|
37
|
+
# UI flow this powers ("add a new MCP server") always validates one server
|
|
38
|
+
# at a time, and keeping the request shape narrow avoids exposing tuple-of-
|
|
39
|
+
# transports semantics the caller doesn't need.
|
|
40
|
+
|
|
41
|
+
_DEFAULT_SERVER_NAME = "test-server"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _StdioMCPServerSpec(BaseModel):
|
|
45
|
+
"""Stdio (subprocess) MCP server spec.
|
|
46
|
+
|
|
47
|
+
Mirrors the subset of ``fastmcp.mcp_config.StdioMCPServer`` fields the
|
|
48
|
+
OpenHands UI exposes today.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
type: Literal["stdio"] = "stdio"
|
|
52
|
+
command: str = Field(..., min_length=1, description="Executable to invoke")
|
|
53
|
+
args: list[str] = Field(default_factory=list)
|
|
54
|
+
env: dict[str, str] = Field(default_factory=dict)
|
|
55
|
+
cwd: str | None = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _RemoteMCPServerSpec(BaseModel):
|
|
59
|
+
"""Remote (HTTP / SSE) MCP server spec."""
|
|
60
|
+
|
|
61
|
+
# ``shttp`` is the alias the OpenHands settings layer uses for
|
|
62
|
+
# streamable-http; we accept both spellings so the UI can forward
|
|
63
|
+
# its own value unchanged.
|
|
64
|
+
type: Literal["http", "shttp", "streamable-http", "sse"]
|
|
65
|
+
url: str = Field(..., min_length=1)
|
|
66
|
+
headers: dict[str, str] = Field(default_factory=dict)
|
|
67
|
+
api_key: str | None = Field(
|
|
68
|
+
default=None,
|
|
69
|
+
description=(
|
|
70
|
+
"Bearer token. If provided, sent as 'Authorization: Bearer <token>'."
|
|
71
|
+
),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def to_fastmcp_dict(self) -> dict[str, Any]:
|
|
75
|
+
# fastmcp's RemoteMCPServer accepts "http", "streamable-http", "sse";
|
|
76
|
+
# collapse the OpenHands-specific "shttp" alias to "http".
|
|
77
|
+
transport = "http" if self.type == "shttp" else self.type
|
|
78
|
+
out: dict[str, Any] = {"url": self.url, "transport": transport}
|
|
79
|
+
headers = dict(self.headers)
|
|
80
|
+
if self.api_key:
|
|
81
|
+
# Don't clobber a caller-provided Authorization header.
|
|
82
|
+
headers.setdefault("Authorization", f"Bearer {self.api_key}")
|
|
83
|
+
if headers:
|
|
84
|
+
out["headers"] = headers
|
|
85
|
+
return out
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class MCPTestRequest(BaseModel):
|
|
89
|
+
"""Body for ``POST /api/mcp/test``."""
|
|
90
|
+
|
|
91
|
+
name: str = Field(
|
|
92
|
+
default=_DEFAULT_SERVER_NAME,
|
|
93
|
+
min_length=1,
|
|
94
|
+
max_length=128,
|
|
95
|
+
description=(
|
|
96
|
+
"Name to use for the server inside the temporary MCPConfig. "
|
|
97
|
+
"Only affects error messages -- does not need to match any "
|
|
98
|
+
"persisted setting."
|
|
99
|
+
),
|
|
100
|
+
)
|
|
101
|
+
server: Annotated[
|
|
102
|
+
_StdioMCPServerSpec | _RemoteMCPServerSpec,
|
|
103
|
+
Field(discriminator="type"),
|
|
104
|
+
]
|
|
105
|
+
timeout: float = Field(
|
|
106
|
+
default=15.0,
|
|
107
|
+
gt=0,
|
|
108
|
+
le=120,
|
|
109
|
+
description="Seconds to wait for connection + tools/list to complete.",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
@model_validator(mode="after")
|
|
113
|
+
def _strip_name(self) -> MCPTestRequest:
|
|
114
|
+
# Mirror the validation MCPConfig itself applies to server keys --
|
|
115
|
+
# whitespace-only names would silently bypass min_length=1 above.
|
|
116
|
+
self.name = self.name.strip() or _DEFAULT_SERVER_NAME
|
|
117
|
+
return self
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class MCPTestSuccess(BaseModel):
|
|
121
|
+
"""Response when the candidate server connects and lists its tools."""
|
|
122
|
+
|
|
123
|
+
ok: Literal[True] = True
|
|
124
|
+
tools: list[str] = Field(
|
|
125
|
+
default_factory=list,
|
|
126
|
+
description="Names of tools advertised by the MCP server.",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class MCPTestFailure(BaseModel):
|
|
131
|
+
"""Response when the candidate server fails to connect or list tools.
|
|
132
|
+
|
|
133
|
+
The endpoint returns HTTP 200 in both success and failure cases: a
|
|
134
|
+
failure here is the *expected* outcome of validating a user-supplied
|
|
135
|
+
config, not a server-side error. The structured shape makes it easy
|
|
136
|
+
for the UI to render an actionable message.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
ok: Literal[False] = False
|
|
140
|
+
error: str = Field(description="Human-readable error message.")
|
|
141
|
+
error_kind: Literal["timeout", "connection", "unknown"] = Field(
|
|
142
|
+
description="Coarse error classification, useful for branching UI."
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
MCPTestResponse = MCPTestSuccess | MCPTestFailure
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
# Endpoint
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _server_to_fastmcp_dict(spec: _StdioMCPServerSpec | _RemoteMCPServerSpec) -> dict:
|
|
155
|
+
if isinstance(spec, _StdioMCPServerSpec):
|
|
156
|
+
out: dict[str, Any] = {"command": spec.command, "args": list(spec.args)}
|
|
157
|
+
if spec.env:
|
|
158
|
+
out["env"] = dict(spec.env)
|
|
159
|
+
if spec.cwd:
|
|
160
|
+
out["cwd"] = spec.cwd
|
|
161
|
+
return out
|
|
162
|
+
return spec.to_fastmcp_dict()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _probe_mcp_server(request: MCPTestRequest) -> MCPTestResponse:
|
|
166
|
+
"""Synchronous probe -- safe to run inside ``run_in_executor``.
|
|
167
|
+
|
|
168
|
+
``create_mcp_tools`` already runs its own event loop in a background
|
|
169
|
+
thread via ``MCPClient.call_async_from_sync``. We deliberately do not
|
|
170
|
+
call it from the FastAPI request task; instead the caller hops into a
|
|
171
|
+
threadpool first.
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
config = {"mcpServers": {request.name: _server_to_fastmcp_dict(request.server)}}
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
# ``create_mcp_tools`` returns a client that owns a background loop
|
|
178
|
+
# and a (possibly long-lived) subprocess. Use the context-manager
|
|
179
|
+
# form so we always tear it down, even when listing succeeded.
|
|
180
|
+
with create_mcp_tools(config, timeout=request.timeout) as client:
|
|
181
|
+
return MCPTestSuccess(tools=[tool.name for tool in client.tools])
|
|
182
|
+
except MCPTimeoutError as exc:
|
|
183
|
+
logger.info("MCP test timed out for server %r: %s", request.name, exc)
|
|
184
|
+
return MCPTestFailure(error=str(exc), error_kind="timeout")
|
|
185
|
+
except MCPError as exc:
|
|
186
|
+
# ``MCPError("MCP Connection Failure")`` is what client.connect()
|
|
187
|
+
# raises when the underlying fastmcp client fails to start. Surface
|
|
188
|
+
# the root-cause message (e.g. "sh: 1: mcp-server-github: Permission
|
|
189
|
+
# denied") because the wrapper alone isn't useful.
|
|
190
|
+
cause = exc.__cause__ or exc.__context__
|
|
191
|
+
detail = str(cause) if cause else str(exc) or "Failed to connect to MCP server"
|
|
192
|
+
logger.info(
|
|
193
|
+
"MCP test connection failed for server %r: %s", request.name, detail
|
|
194
|
+
)
|
|
195
|
+
return MCPTestFailure(error=detail, error_kind="connection")
|
|
196
|
+
except Exception as exc: # noqa: BLE001 - we want to surface anything else
|
|
197
|
+
# Any other exception is unexpected but should still return a
|
|
198
|
+
# structured response: the UI can't recover from a 500.
|
|
199
|
+
logger.warning(
|
|
200
|
+
"MCP test failed unexpectedly for server %r",
|
|
201
|
+
request.name,
|
|
202
|
+
exc_info=True,
|
|
203
|
+
)
|
|
204
|
+
return MCPTestFailure(
|
|
205
|
+
error=f"{type(exc).__name__}: {exc}" if str(exc) else type(exc).__name__,
|
|
206
|
+
error_kind="unknown",
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@mcp_router.post(
|
|
211
|
+
"/test",
|
|
212
|
+
response_model=MCPTestResponse,
|
|
213
|
+
summary="Test an MCP server configuration",
|
|
214
|
+
description=(
|
|
215
|
+
"Attempt to connect to a candidate MCP server and list its tools, "
|
|
216
|
+
"without persisting any settings. Useful for validating user input "
|
|
217
|
+
"in 'add MCP server' flows before storing the config. "
|
|
218
|
+
"Returns 200 with `ok=false` for connection / timeout failures "
|
|
219
|
+
"(those are expected during validation, not server errors)."
|
|
220
|
+
),
|
|
221
|
+
)
|
|
222
|
+
async def test_mcp_server(request: MCPTestRequest) -> MCPTestResponse:
|
|
223
|
+
"""Probe a single MCP server config and report whether it works."""
|
|
224
|
+
loop = asyncio.get_running_loop()
|
|
225
|
+
return await loop.run_in_executor(None, _probe_mcp_server, request)
|