openhands-agent-server 1.25.0__tar.gz → 1.27.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.
Files changed (61) hide show
  1. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/PKG-INFO +1 -1
  2. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands/agent_server/api.py +12 -5
  3. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands/agent_server/conversation_service.py +8 -2
  4. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands/agent_server/mcp_router.py +142 -12
  5. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands/agent_server/persistence/models.py +45 -4
  6. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands/agent_server/settings_router.py +17 -7
  7. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands/agent_server/skills_service.py +3 -3
  8. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands_agent_server.egg-info/PKG-INFO +1 -1
  9. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands_agent_server.egg-info/SOURCES.txt +0 -2
  10. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/pyproject.toml +1 -1
  11. openhands_agent_server-1.25.0/openhands/agent_server/conversation_router_acp.py +0 -185
  12. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands/agent_server/__init__.py +0 -0
  13. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands/agent_server/__main__.py +0 -0
  14. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands/agent_server/_secrets_exposure.py +0 -0
  15. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands/agent_server/auth_router.py +0 -0
  16. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands/agent_server/bash_router.py +0 -0
  17. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands/agent_server/bash_service.py +0 -0
  18. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands/agent_server/cloud_proxy_router.py +0 -0
  19. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands/agent_server/config.py +0 -0
  20. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands/agent_server/conversation_lease.py +0 -0
  21. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands/agent_server/conversation_router.py +0 -0
  22. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands/agent_server/dependencies.py +0 -0
  23. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands/agent_server/desktop_router.py +0 -0
  24. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands/agent_server/desktop_service.py +0 -0
  25. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands/agent_server/docker/Dockerfile +0 -0
  26. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands/agent_server/docker/build.py +0 -0
  27. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands/agent_server/docker/wallpaper.svg +0 -0
  28. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands/agent_server/env_parser.py +0 -0
  29. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands/agent_server/event_router.py +0 -0
  30. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands/agent_server/event_service.py +0 -0
  31. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands/agent_server/file_router.py +0 -0
  32. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands/agent_server/git_router.py +0 -0
  33. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands/agent_server/hooks_router.py +0 -0
  34. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands/agent_server/hooks_service.py +0 -0
  35. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands/agent_server/llm_router.py +0 -0
  36. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands/agent_server/logging_config.py +0 -0
  37. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands/agent_server/middleware.py +0 -0
  38. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands/agent_server/models.py +0 -0
  39. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands/agent_server/openapi.py +0 -0
  40. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands/agent_server/persistence/__init__.py +0 -0
  41. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands/agent_server/persistence/store.py +0 -0
  42. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands/agent_server/profiles_router.py +0 -0
  43. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands/agent_server/pub_sub.py +0 -0
  44. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands/agent_server/py.typed +0 -0
  45. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands/agent_server/server_details_router.py +0 -0
  46. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands/agent_server/skills_router.py +0 -0
  47. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands/agent_server/sockets.py +0 -0
  48. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands/agent_server/tool_preload_service.py +0 -0
  49. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands/agent_server/tool_router.py +0 -0
  50. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands/agent_server/utils.py +0 -0
  51. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands/agent_server/vscode_extensions/openhands-settings/extension.js +0 -0
  52. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands/agent_server/vscode_extensions/openhands-settings/package.json +0 -0
  53. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands/agent_server/vscode_router.py +0 -0
  54. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands/agent_server/vscode_service.py +0 -0
  55. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands/agent_server/workspace_router.py +0 -0
  56. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands/agent_server/workspaces_router.py +0 -0
  57. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands_agent_server.egg-info/dependency_links.txt +0 -0
  58. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands_agent_server.egg-info/entry_points.txt +0 -0
  59. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands_agent_server.egg-info/requires.txt +0 -0
  60. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/openhands_agent_server.egg-info/top_level.txt +0 -0
  61. {openhands_agent_server-1.25.0 → openhands_agent_server-1.27.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openhands-agent-server
3
- Version: 1.25.0
3
+ Version: 1.27.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
@@ -2,6 +2,7 @@ import asyncio
2
2
  import os
3
3
  import tempfile
4
4
  import traceback
5
+ import uuid
5
6
  from collections.abc import AsyncIterator, Sequence
6
7
  from contextlib import asynccontextmanager, suppress
7
8
  from pathlib import Path
@@ -24,7 +25,6 @@ from openhands.agent_server.config import (
24
25
  get_default_config,
25
26
  )
26
27
  from openhands.agent_server.conversation_router import conversation_router
27
- from openhands.agent_server.conversation_router_acp import conversation_router_acp
28
28
  from openhands.agent_server.conversation_service import (
29
29
  get_default_conversation_service,
30
30
  )
@@ -300,7 +300,6 @@ def _add_api_routes(app: FastAPI, config: Config) -> None:
300
300
  api_router = APIRouter(prefix="/api", dependencies=dependencies)
301
301
  api_router.include_router(event_router)
302
302
  api_router.include_router(conversation_router)
303
- api_router.include_router(conversation_router_acp)
304
303
  api_router.include_router(tool_router)
305
304
  api_router.include_router(bash_router)
306
305
  api_router.include_router(git_router)
@@ -431,18 +430,24 @@ def _add_exception_handlers(api: FastAPI) -> None:
431
430
  request: Request, exc: Exception
432
431
  ) -> JSONResponse:
433
432
  """Handle unhandled exceptions."""
433
+ # Correlation id that ties the 500 a caller receives to the server-side
434
+ # log line (with full traceback) for this failure, so an otherwise
435
+ # opaque 500 can be matched to its traceback in the server logs.
436
+ error_id = uuid.uuid4().hex
434
437
  # Always log that we're in the exception handler for debugging
435
438
  logger.debug(
436
- "Exception handler called for %s %s with %s: %s",
439
+ "Exception handler called for %s %s with %s: %s [error_id=%s]",
437
440
  request.method,
438
441
  request.url.path,
439
442
  type(exc).__name__,
440
443
  str(exc),
444
+ error_id,
441
445
  )
442
446
 
443
447
  content = {
444
448
  "detail": "Internal Server Error",
445
449
  "exception": str(exc),
450
+ "error_id": error_id,
446
451
  }
447
452
  # In DEBUG mode, include stack trace in response
448
453
  if DEBUG:
@@ -458,9 +463,10 @@ def _add_exception_handlers(api: FastAPI) -> None:
458
463
  return await _http_exception_handler(request, http_exc)
459
464
  # If no HTTPException found, treat as unhandled exception
460
465
  logger.error(
461
- "Unhandled ExceptionGroup on %s %s",
466
+ "Unhandled ExceptionGroup on %s %s [error_id=%s]",
462
467
  request.method,
463
468
  request.url.path,
469
+ error_id,
464
470
  exc_info=(type(exc), exc, exc.__traceback__),
465
471
  )
466
472
  return JSONResponse(status_code=500, content=content)
@@ -468,9 +474,10 @@ def _add_exception_handlers(api: FastAPI) -> None:
468
474
  # Logs full stack trace for any unhandled error that FastAPI would
469
475
  # turn into a 500
470
476
  logger.error(
471
- "Unhandled exception on %s %s",
477
+ "Unhandled exception on %s %s [error_id=%s]",
472
478
  request.method,
473
479
  request.url.path,
480
+ error_id,
474
481
  exc_info=(type(exc), exc, exc.__traceback__),
475
482
  )
476
483
  return JSONResponse(status_code=500, content=content)
@@ -1256,9 +1256,15 @@ class WebhookSubscriber(Subscriber):
1256
1256
  if self.session_api_key:
1257
1257
  headers["X-Session-API-Key"] = self.session_api_key
1258
1258
 
1259
- # Convert events to serializable format
1259
+ # Convert events to a JSON-serializable format. mode="json" is required
1260
+ # so types like set and SecretStr become JSON-safe primitives; without
1261
+ # it httpx's encoder raises "Object of type set/SecretStr is not JSON
1262
+ # serializable", every retry fails identically, and the events are
1263
+ # dropped. (Mirrors ConversationWebhookSubscriber.post_conversation_info.)
1260
1264
  event_data = [
1261
- event.model_dump() if hasattr(event, "model_dump") else event.__dict__
1265
+ event.model_dump(mode="json")
1266
+ if hasattr(event, "model_dump")
1267
+ else event.__dict__
1262
1268
  for event in events_to_post
1263
1269
  ]
1264
1270
 
@@ -6,9 +6,12 @@ to settings, where a misconfiguration would otherwise surface only at
6
6
  conversation start (and there manifest as a noisy traceback that aborts
7
7
  agent initialization).
8
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.
9
+ The endpoint never mutates server state or touches stored settings: it
10
+ spins up the MCP connection, lists the advertised tools, optionally invokes
11
+ one caller-chosen tool (``tool_call``), then tears the connection down.
12
+ The optional tool call exists because listing tools does not exercise the
13
+ credentials many servers only use inside tool handlers (e.g. the Slack MCP
14
+ server starts fine with a bogus token); callers must pick a read-only tool.
12
15
  """
13
16
 
14
17
  from __future__ import annotations
@@ -16,12 +19,16 @@ from __future__ import annotations
16
19
  import asyncio
17
20
  from typing import Annotated, Any, Literal
18
21
 
19
- from fastapi import APIRouter
22
+ import mcp.types
23
+ from fastapi import APIRouter, Request
20
24
  from pydantic import BaseModel, Field, model_validator
21
25
 
26
+ from openhands.agent_server._secrets_exposure import get_cipher
22
27
  from openhands.sdk.logger import get_logger
23
28
  from openhands.sdk.mcp import create_mcp_tools
24
29
  from openhands.sdk.mcp.exceptions import MCPError, MCPTimeoutError
30
+ from openhands.sdk.utils.cipher import Cipher
31
+ from openhands.sdk.utils.pydantic_secrets import decrypt_str_with_cipher_or_keep
25
32
 
26
33
 
27
34
  logger = get_logger(__name__)
@@ -85,6 +92,22 @@ class _RemoteMCPServerSpec(BaseModel):
85
92
  return out
86
93
 
87
94
 
95
+ class MCPToolCallSpec(BaseModel):
96
+ """A single tool invocation to run as part of the connection test.
97
+
98
+ Listing tools does not exercise the credentials many servers only use
99
+ inside tool handlers, so callers can name one tool to invoke after the
100
+ listing succeeds. Callers are responsible for choosing a read-only tool;
101
+ the endpoint executes it verbatim.
102
+ """
103
+
104
+ name: str = Field(..., min_length=1, description="Name of the tool to invoke")
105
+ arguments: dict[str, Any] = Field(
106
+ default_factory=dict,
107
+ description="Arguments passed to the tool unchanged.",
108
+ )
109
+
110
+
88
111
  class MCPTestRequest(BaseModel):
89
112
  """Body for ``POST /api/mcp/test``."""
90
113
 
@@ -108,6 +131,15 @@ class MCPTestRequest(BaseModel):
108
131
  le=120,
109
132
  description="Seconds to wait for connection + tools/list to complete.",
110
133
  )
134
+ tool_call: MCPToolCallSpec | None = Field(
135
+ default=None,
136
+ description=(
137
+ "Optional read-only tool to invoke after listing succeeds, so "
138
+ "callers can verify credentials the server only exercises on "
139
+ "tool invocation. Its outcome is reported verbatim in "
140
+ "`tool_result` without affecting `ok`."
141
+ ),
142
+ )
111
143
 
112
144
  @model_validator(mode="after")
113
145
  def _strip_name(self) -> MCPTestRequest:
@@ -117,6 +149,19 @@ class MCPTestRequest(BaseModel):
117
149
  return self
118
150
 
119
151
 
152
+ class MCPToolCallResult(BaseModel):
153
+ """Verbatim outcome of the requested ``tool_call``.
154
+
155
+ The endpoint stays provider-neutral: many servers report upstream
156
+ failures (e.g. Slack's ``{"ok": false, "error": "invalid_auth"}``)
157
+ as ordinary text content with ``isError`` unset, so interpreting the
158
+ payload is the caller's job.
159
+ """
160
+
161
+ is_error: bool = Field(description="The MCP-level isError flag of the result.")
162
+ text: str = Field(description="Concatenated text content of the result.")
163
+
164
+
120
165
  class MCPTestSuccess(BaseModel):
121
166
  """Response when the candidate server connects and lists its tools."""
122
167
 
@@ -125,6 +170,10 @@ class MCPTestSuccess(BaseModel):
125
170
  default_factory=list,
126
171
  description="Names of tools advertised by the MCP server.",
127
172
  )
173
+ tool_result: MCPToolCallResult | None = Field(
174
+ default=None,
175
+ description=("Outcome of the requested `tool_call`, when one was supplied."),
176
+ )
128
177
 
129
178
 
130
179
  class MCPTestFailure(BaseModel):
@@ -151,18 +200,81 @@ MCPTestResponse = MCPTestSuccess | MCPTestFailure
151
200
  # ---------------------------------------------------------------------------
152
201
 
153
202
 
154
- def _server_to_fastmcp_dict(spec: _StdioMCPServerSpec | _RemoteMCPServerSpec) -> dict:
203
+ def _decrypt_mapping(cipher: Cipher | None, mapping: dict[str, str]) -> dict[str, str]:
204
+ """Decrypt Fernet-encrypted values round-tripped from settings.
205
+
206
+ The GUI fetches stored settings with ``X-Expose-Secrets: encrypted`` and
207
+ forwards the ciphertext unchanged so the edit flow can test the *real*
208
+ stored credentials without ever seeing them. Plaintext values (the
209
+ common case: freshly typed input) pass through untouched.
210
+ """
211
+ if cipher is None:
212
+ return dict(mapping)
213
+ return {
214
+ key: decrypt_str_with_cipher_or_keep(
215
+ cipher, value, description="MCP test env/headers"
216
+ )
217
+ for key, value in mapping.items()
218
+ }
219
+
220
+
221
+ def _server_to_fastmcp_dict(
222
+ spec: _StdioMCPServerSpec | _RemoteMCPServerSpec, cipher: Cipher | None
223
+ ) -> dict:
155
224
  if isinstance(spec, _StdioMCPServerSpec):
156
225
  out: dict[str, Any] = {"command": spec.command, "args": list(spec.args)}
157
226
  if spec.env:
158
- out["env"] = dict(spec.env)
227
+ out["env"] = _decrypt_mapping(cipher, spec.env)
159
228
  if spec.cwd:
160
229
  out["cwd"] = spec.cwd
161
230
  return out
162
- return spec.to_fastmcp_dict()
231
+ remote = spec.to_fastmcp_dict()
232
+ if "headers" in remote:
233
+ remote["headers"] = _decrypt_mapping(cipher, remote["headers"])
234
+ return remote
235
+
236
+
237
+ def _run_tool_call(
238
+ client: Any, spec: MCPToolCallSpec, tool_names: list[str], timeout: float
239
+ ) -> MCPToolCallResult:
240
+ """Invoke the requested tool on the connected client.
241
+
242
+ Uses ``call_tool_mcp`` (not ``call_tool``, which raises on ``isError``)
243
+ so in-band failures come back as data -- mirrors ``MCPToolExecutor``.
244
+ A timeout is reported as an errored result rather than failing the
245
+ whole test: the server did connect and list, which is still useful.
246
+ """
247
+ if spec.name not in tool_names:
248
+ return MCPToolCallResult(
249
+ is_error=True,
250
+ text=(
251
+ f"Tool {spec.name!r} not advertised by server "
252
+ f"(available: {', '.join(tool_names) or 'none'})"
253
+ ),
254
+ )
255
+ try:
256
+ result: mcp.types.CallToolResult = client.call_async_from_sync(
257
+ client.call_tool_mcp,
258
+ name=spec.name,
259
+ arguments=spec.arguments,
260
+ timeout=timeout,
261
+ )
262
+ except TimeoutError:
263
+ return MCPToolCallResult(
264
+ is_error=True,
265
+ text=f"Tool {spec.name!r} call timed out after {timeout} seconds",
266
+ )
267
+ text = "\n".join(
268
+ block.text
269
+ for block in result.content
270
+ if isinstance(block, mcp.types.TextContent)
271
+ )
272
+ return MCPToolCallResult(is_error=bool(result.isError), text=text)
163
273
 
164
274
 
165
- def _probe_mcp_server(request: MCPTestRequest) -> MCPTestResponse:
275
+ def _probe_mcp_server(
276
+ request: MCPTestRequest, cipher: Cipher | None
277
+ ) -> MCPTestResponse:
166
278
  """Synchronous probe -- safe to run inside ``run_in_executor``.
167
279
 
168
280
  ``create_mcp_tools`` already runs its own event loop in a background
@@ -171,14 +283,22 @@ def _probe_mcp_server(request: MCPTestRequest) -> MCPTestResponse:
171
283
  threadpool first.
172
284
  """
173
285
 
174
- config = {"mcpServers": {request.name: _server_to_fastmcp_dict(request.server)}}
286
+ config = {
287
+ "mcpServers": {request.name: _server_to_fastmcp_dict(request.server, cipher)}
288
+ }
175
289
 
176
290
  try:
177
291
  # ``create_mcp_tools`` returns a client that owns a background loop
178
292
  # and a (possibly long-lived) subprocess. Use the context-manager
179
293
  # form so we always tear it down, even when listing succeeded.
180
294
  with create_mcp_tools(config, timeout=request.timeout) as client:
181
- return MCPTestSuccess(tools=[tool.name for tool in client.tools])
295
+ tool_names = [tool.name for tool in client.tools]
296
+ tool_result: MCPToolCallResult | None = None
297
+ if request.tool_call is not None:
298
+ tool_result = _run_tool_call(
299
+ client, request.tool_call, tool_names, request.timeout
300
+ )
301
+ return MCPTestSuccess(tools=tool_names, tool_result=tool_result)
182
302
  except MCPTimeoutError as exc:
183
303
  logger.info("MCP test timed out for server %r: %s", request.name, exc)
184
304
  return MCPTestFailure(error=str(exc), error_kind="timeout")
@@ -215,11 +335,21 @@ def _probe_mcp_server(request: MCPTestRequest) -> MCPTestResponse:
215
335
  "Attempt to connect to a candidate MCP server and list its tools, "
216
336
  "without persisting any settings. Useful for validating user input "
217
337
  "in 'add MCP server' flows before storing the config. "
338
+ "Optionally invokes one caller-chosen (read-only) tool via "
339
+ "`tool_call` and reports its outcome in `tool_result`, so callers "
340
+ "can verify credentials that are only exercised on tool invocation. "
341
+ "Encrypted `env`/`headers` values round-tripped from settings are "
342
+ "decrypted before the connection is attempted. "
218
343
  "Returns 200 with `ok=false` for connection / timeout failures "
219
344
  "(those are expected during validation, not server errors)."
220
345
  ),
221
346
  )
222
- async def test_mcp_server(request: MCPTestRequest) -> MCPTestResponse:
347
+ async def test_mcp_server(
348
+ request: MCPTestRequest, http_request: Request
349
+ ) -> MCPTestResponse:
223
350
  """Probe a single MCP server config and report whether it works."""
351
+ # Resolve the cipher here: the threadpool function below must not
352
+ # reach back into ``http_request.app.state``.
353
+ cipher = get_cipher(http_request)
224
354
  loop = asyncio.get_running_loop()
225
- return await loop.run_in_executor(None, _probe_mcp_server, request)
355
+ return await loop.run_in_executor(None, _probe_mcp_server, request, cipher)
@@ -34,16 +34,23 @@ from openhands.sdk.utils.pydantic_secrets import serialize_secret, validate_secr
34
34
  class SettingsUpdatePayload(TypedDict, total=False):
35
35
  """Typed payload for PersistedSettings.update() method.
36
36
 
37
- The ``*_diff`` dicts are deep-merged via :func:`_deep_merge`: nested
37
+ All three ``*_diff`` dicts are deep-merged via :func:`_deep_merge`: nested
38
38
  objects merge recursively, and a ``None`` value *inside a nested map*
39
39
  deletes that entry (the "unset" primitive) — e.g. send
40
40
  ``{"acp_env": {"NAME": None}}`` to drop one env-var without re-sending the
41
41
  whole map. A ``None`` on a top-level *field* is not treated as delete; it
42
42
  flows to validation as before.
43
+
44
+ ``misc_settings_diff`` is deep-merged into the persisted ``misc_settings``
45
+ block. The agent-server treats ``misc_settings`` as opaque
46
+ frontend-owned data (it persists and merges, but does not interpret), so
47
+ any shape the client chooses is valid; lists are replaced wholesale by
48
+ the deep-merge.
43
49
  """
44
50
 
45
51
  agent_settings_diff: dict[str, Any]
46
52
  conversation_settings_diff: dict[str, Any]
53
+ misc_settings_diff: dict[str, Any]
47
54
  active_profile: str | None
48
55
 
49
56
 
@@ -97,7 +104,7 @@ def _deep_merge(
97
104
  return result
98
105
 
99
106
 
100
- PERSISTED_SETTINGS_SCHEMA_VERSION = 1
107
+ PERSISTED_SETTINGS_SCHEMA_VERSION = 2
101
108
 
102
109
 
103
110
  class PersistedSettings(BaseModel):
@@ -109,6 +116,12 @@ class PersistedSettings(BaseModel):
109
116
 
110
117
  The ``active_profile`` field tracks which LLM profile was last activated,
111
118
  allowing frontends to display which profile is currently in use.
119
+
120
+ The ``misc_settings`` field is an opaque dict the agent-server persists
121
+ on behalf of the frontend. The agent-server never reads its contents and
122
+ has no schema for it; clients are free to store any JSON-serializable
123
+ structure they need (e.g. app/UI preferences, analytics consent, git
124
+ identity used for in-conversation commits, etc.).
112
125
  """
113
126
 
114
127
  schema_version: int = Field(
@@ -124,6 +137,14 @@ class PersistedSettings(BaseModel):
124
137
  default=None,
125
138
  description="Name of the currently active LLM profile.",
126
139
  )
140
+ misc_settings: dict[str, Any] = Field(
141
+ default_factory=dict,
142
+ description=(
143
+ "Opaque dict the agent-server persists on behalf of the frontend. "
144
+ "Updated through misc_settings_diff (deep-merged); contents are "
145
+ "never read or validated by the agent-server."
146
+ ),
147
+ )
127
148
 
128
149
  model_config = ConfigDict(populate_by_name=True)
129
150
 
@@ -173,7 +194,7 @@ class PersistedSettings(BaseModel):
173
194
  agent_update = payload.get("agent_settings_diff")
174
195
  conv_update = payload.get("conversation_settings_diff")
175
196
 
176
- # Phase 1: Validate both updates before any mutations
197
+ # Phase 1: Validate all updates before any mutations
177
198
  new_agent: AgentSettingsConfig | None = None
178
199
  new_conv: ConversationSettings | None = None
179
200
  agent_merged: dict | None = None
@@ -232,11 +253,23 @@ class PersistedSettings(BaseModel):
232
253
  f"Failed to update conversation settings: {type(e).__name__}"
233
254
  ) from None
234
255
 
256
+ # ``misc_settings`` is opaque: deep-merge without schema
257
+ # validation. The agent-server doesn't interpret what's inside,
258
+ # and ``misc_settings`` is not a secret container — the merged
259
+ # dict is therefore stored directly without the post-commit
260
+ # clear-down used by ``agent_settings`` / ``conversation_settings``.
261
+ misc_update = payload.get("misc_settings_diff")
262
+ new_misc: dict[str, Any] | None = None
263
+ if isinstance(misc_update, dict):
264
+ new_misc = _deep_merge(self.misc_settings, misc_update)
265
+
235
266
  # Phase 2: Apply validated changes atomically
236
267
  if new_agent is not None:
237
268
  self.agent_settings = new_agent
238
269
  if new_conv is not None:
239
270
  self.conversation_settings = new_conv
271
+ if new_misc is not None:
272
+ self.misc_settings = new_misc
240
273
 
241
274
  # Update active_profile if explicitly provided (including None to clear)
242
275
  if "active_profile" in payload:
@@ -252,7 +285,14 @@ class PersistedSettings(BaseModel):
252
285
  def from_persisted(
253
286
  cls, data: Any, *, context: dict[str, Any] | None = None
254
287
  ) -> PersistedSettings:
255
- """Load persisted settings, applying top-level and nested migrations."""
288
+ """Load persisted settings.
289
+
290
+ Schema-version history:
291
+
292
+ - **v1**: ``agent_settings`` + ``conversation_settings`` only.
293
+ Missing ``misc_settings`` defaults to an empty dict.
294
+ - **v2** (current): adds the opaque ``misc_settings`` container.
295
+ """
256
296
  if not isinstance(data, dict):
257
297
  return cls.model_validate(data, context=context)
258
298
 
@@ -266,6 +306,7 @@ class PersistedSettings(BaseModel):
266
306
  f"{version} is newer than supported version "
267
307
  f"{PERSISTED_SETTINGS_SCHEMA_VERSION}"
268
308
  )
309
+
269
310
  payload["schema_version"] = PERSISTED_SETTINGS_SCHEMA_VERSION
270
311
  return cls.model_validate(payload, context=context)
271
312
 
@@ -160,6 +160,7 @@ async def get_settings(request: Request) -> SettingsResponse:
160
160
  mode="json"
161
161
  ),
162
162
  llm_api_key_is_set=settings.llm_api_key_is_set,
163
+ misc_settings=settings.misc_settings,
163
164
  )
164
165
 
165
166
 
@@ -169,11 +170,12 @@ async def update_settings(
169
170
  ) -> SettingsResponse:
170
171
  """Update settings with partial changes.
171
172
 
172
- Accepts ``agent_settings_diff`` and/or ``conversation_settings_diff``
173
- for incremental updates. Diffs are deep-merged; nested objects merge
174
- recursively, and a ``null`` value **inside a nested map deletes that
175
- entry** — the "unset" primitive that lets a client remove a single map
176
- key without round-tripping the whole map. To drop one ACP env-var::
173
+ Accepts ``agent_settings_diff``, ``conversation_settings_diff``, and/or
174
+ ``misc_settings_diff`` for incremental updates. All three are deep-merged;
175
+ nested objects merge recursively, and a ``null`` value **inside a nested
176
+ map deletes that entry** — the "unset" primitive that lets a client
177
+ remove a single map key without round-tripping the whole map. To drop one
178
+ ACP env-var::
177
179
 
178
180
  PATCH /api/settings
179
181
  {"agent_settings_diff": {"acp_env": {"STALE_KEY": null}}}
@@ -187,6 +189,11 @@ async def update_settings(
187
189
  is **not** an unset — it flows to model validation as before, so it still
188
190
  fails loudly rather than silently resetting the field to its default.
189
191
 
192
+ ``misc_settings_diff`` is deep-merged into the persisted ``misc_settings``
193
+ block. The agent-server treats ``misc_settings`` as opaque frontend-owned
194
+ data: nested dicts are merged recursively, lists are replaced wholesale,
195
+ and the contents are never read or validated server-side.
196
+
190
197
  Uses file locking to prevent concurrent updates from overwriting each other.
191
198
 
192
199
  Raises:
@@ -201,8 +208,9 @@ async def update_settings(
201
208
  raise HTTPException(
202
209
  status_code=400,
203
210
  detail=(
204
- "At least one of agent_settings_diff or "
205
- "conversation_settings_diff must be provided"
211
+ "At least one of agent_settings_diff, "
212
+ "conversation_settings_diff, or misc_settings_diff "
213
+ "must be provided"
206
214
  ),
207
215
  )
208
216
 
@@ -223,6 +231,7 @@ async def update_settings(
223
231
  "conversation_settings_modified": (
224
232
  "conversation_settings_diff" in update_data
225
233
  ),
234
+ "misc_settings_modified": "misc_settings_diff" in update_data,
226
235
  },
227
236
  )
228
237
  except (ValueError, ValidationError):
@@ -256,6 +265,7 @@ async def update_settings(
256
265
  agent_settings=settings.agent_settings.model_dump(mode="json"),
257
266
  conversation_settings=settings.conversation_settings.model_dump(mode="json"),
258
267
  llm_api_key_is_set=settings.llm_api_key_is_set,
268
+ misc_settings=settings.misc_settings,
259
269
  )
260
270
 
261
271
 
@@ -40,7 +40,7 @@ from openhands.sdk.skills import (
40
40
  )
41
41
  from openhands.sdk.skills.skill import (
42
42
  DEFAULT_MARKETPLACE_PATH,
43
- PUBLIC_SKILLS_BRANCH,
43
+ PUBLIC_SKILLS_REF,
44
44
  PUBLIC_SKILLS_REPO,
45
45
  _invalidate_public_skills_cache,
46
46
  load_skills_from_dir,
@@ -391,7 +391,7 @@ def sync_public_skills() -> tuple[bool, str]:
391
391
  try:
392
392
  cache_dir = get_skills_cache_dir()
393
393
  result = update_skills_repository(
394
- PUBLIC_SKILLS_REPO, PUBLIC_SKILLS_BRANCH, cache_dir
394
+ PUBLIC_SKILLS_REPO, PUBLIC_SKILLS_REF, cache_dir
395
395
  )
396
396
 
397
397
  if result:
@@ -634,7 +634,7 @@ def _fetch_catalog_entries(marketplace_path: str) -> list[_CatalogEntry]:
634
634
  """
635
635
  cache_dir = get_skills_cache_dir()
636
636
  repo_path = update_skills_repository(
637
- PUBLIC_SKILLS_REPO, PUBLIC_SKILLS_BRANCH, cache_dir
637
+ PUBLIC_SKILLS_REPO, PUBLIC_SKILLS_REF, cache_dir
638
638
  )
639
639
 
640
640
  if repo_path is None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openhands-agent-server
3
- Version: 1.25.0
3
+ Version: 1.27.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
@@ -10,7 +10,6 @@ pyproject.toml
10
10
  ./openhands/agent_server/config.py
11
11
  ./openhands/agent_server/conversation_lease.py
12
12
  ./openhands/agent_server/conversation_router.py
13
- ./openhands/agent_server/conversation_router_acp.py
14
13
  ./openhands/agent_server/conversation_service.py
15
14
  ./openhands/agent_server/dependencies.py
16
15
  ./openhands/agent_server/desktop_router.py
@@ -62,7 +61,6 @@ openhands/agent_server/cloud_proxy_router.py
62
61
  openhands/agent_server/config.py
63
62
  openhands/agent_server/conversation_lease.py
64
63
  openhands/agent_server/conversation_router.py
65
- openhands/agent_server/conversation_router_acp.py
66
64
  openhands/agent_server/conversation_service.py
67
65
  openhands/agent_server/dependencies.py
68
66
  openhands/agent_server/desktop_router.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "openhands-agent-server"
3
- version = "1.25.0"
3
+ version = "1.27.0"
4
4
  description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
5
5
 
6
6
  requires-python = ">=3.12"
@@ -1,185 +0,0 @@
1
- """ACP-capable conversation routes for the schema-sensitive endpoints."""
2
-
3
- # Deprecated REST contract: all /api/acp/conversations routes were deprecated
4
- # in v1.22.0 and are scheduled for removal in v1.27.0. The standard
5
- # FastAPI/OpenAPI deprecation marker for routes is ``deprecated=True`` on each
6
- # route decorator; keep matching docstring notices for CI deprecation checks.
7
-
8
- from typing import Annotated
9
- from uuid import UUID
10
-
11
- from fastapi import APIRouter, Body, Depends, HTTPException, Query, Response, status
12
- from pydantic import SecretStr
13
-
14
- from openhands.agent_server.conversation_service import ConversationService
15
- from openhands.agent_server.dependencies import get_conversation_service
16
- from openhands.agent_server.models import (
17
- INCLUDE_SKILLS_PARAM_TITLE,
18
- ACPConversationInfo,
19
- ACPConversationPage,
20
- ConversationSortOrder,
21
- SendMessageRequest,
22
- StartACPConversationRequest,
23
- trim_conversation_response_skills,
24
- )
25
- from openhands.sdk import LLM, Agent, TextContent
26
- from openhands.sdk.agent.acp_agent import ACPAgent
27
- from openhands.sdk.conversation.state import ConversationExecutionStatus
28
- from openhands.sdk.workspace import LocalWorkspace
29
- from openhands.tools.preset.default import get_default_tools
30
-
31
-
32
- conversation_router_acp = APIRouter(
33
- prefix="/acp/conversations",
34
- tags=["ACP Conversations"],
35
- )
36
-
37
- START_ACP_CONVERSATION_EXAMPLES = [
38
- StartACPConversationRequest(
39
- agent=Agent(
40
- llm=LLM(
41
- usage_id="your-llm-service",
42
- model="your-model-provider/your-model-name",
43
- api_key=SecretStr("your-api-key-here"),
44
- ),
45
- tools=get_default_tools(enable_browser=True),
46
- ),
47
- workspace=LocalWorkspace(working_dir="workspace/project"),
48
- initial_message=SendMessageRequest(
49
- role="user", content=[TextContent(text="Flip a coin!")]
50
- ),
51
- ).model_dump(exclude_defaults=True, mode="json"),
52
- StartACPConversationRequest(
53
- agent=ACPAgent(acp_command=["npx", "-y", "claude-agent-acp"]),
54
- workspace=LocalWorkspace(working_dir="workspace/project"),
55
- initial_message=SendMessageRequest(
56
- role="user",
57
- content=[TextContent(text="Inspect the repository and summarize it.")],
58
- ),
59
- ).model_dump(exclude_defaults=True, mode="json"),
60
- ]
61
-
62
-
63
- @conversation_router_acp.get("/search", deprecated=True)
64
- async def search_acp_conversations(
65
- page_id: Annotated[
66
- str | None,
67
- Query(title="Optional next_page_id from the previously returned page"),
68
- ] = None,
69
- limit: Annotated[
70
- int,
71
- Query(title="The max number of results in the page", gt=0, lte=100),
72
- ] = 100,
73
- status: Annotated[
74
- ConversationExecutionStatus | None,
75
- Query(title="Optional filter by conversation execution status"),
76
- ] = None,
77
- sort_order: Annotated[
78
- ConversationSortOrder,
79
- Query(title="Sort order for conversations"),
80
- ] = ConversationSortOrder.CREATED_AT_DESC,
81
- include_skills: Annotated[bool, Query(title=INCLUDE_SKILLS_PARAM_TITLE)] = False,
82
- conversation_service: ConversationService = Depends(get_conversation_service),
83
- ) -> ACPConversationPage:
84
- """Search conversations using the ACP-capable contract.
85
-
86
- Deprecated since v1.22.0 and scheduled for removal in v1.27.0.
87
- Use ``/api/conversations/search`` instead.
88
- """
89
- assert limit > 0
90
- assert limit <= 100
91
- page = await conversation_service.search_acp_conversations(
92
- page_id, limit, status, sort_order
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
103
-
104
-
105
- @conversation_router_acp.get("/count", deprecated=True)
106
- async def count_acp_conversations(
107
- status: Annotated[
108
- ConversationExecutionStatus | None,
109
- Query(title="Optional filter by conversation execution status"),
110
- ] = None,
111
- conversation_service: ConversationService = Depends(get_conversation_service),
112
- ) -> int:
113
- """Count conversations using the ACP-capable contract.
114
-
115
- Deprecated since v1.22.0 and scheduled for removal in v1.27.0.
116
- Use ``/api/conversations/count`` instead.
117
- """
118
- return await conversation_service.count_conversations(status)
119
-
120
-
121
- @conversation_router_acp.get(
122
- "/{conversation_id}",
123
- responses={404: {"description": "Item not found"}},
124
- deprecated=True,
125
- )
126
- async def get_acp_conversation(
127
- conversation_id: UUID,
128
- include_skills: Annotated[bool, Query(title=INCLUDE_SKILLS_PARAM_TITLE)] = False,
129
- conversation_service: ConversationService = Depends(get_conversation_service),
130
- ) -> ACPConversationInfo:
131
- """Get a conversation using the ACP-capable contract.
132
-
133
- Deprecated since v1.22.0 and scheduled for removal in v1.27.0.
134
- Use ``/api/conversations/{conversation_id}`` instead.
135
- """
136
- conversation = await conversation_service.get_acp_conversation(conversation_id)
137
- if conversation is None:
138
- raise HTTPException(status.HTTP_404_NOT_FOUND)
139
- if not include_skills:
140
- conversation = trim_conversation_response_skills(conversation)
141
- return conversation
142
-
143
-
144
- @conversation_router_acp.get("", deprecated=True)
145
- async def batch_get_acp_conversations(
146
- ids: Annotated[list[UUID], Query()],
147
- include_skills: Annotated[bool, Query(title=INCLUDE_SKILLS_PARAM_TITLE)] = False,
148
- conversation_service: ConversationService = Depends(get_conversation_service),
149
- ) -> list[ACPConversationInfo | None]:
150
- """Batch get conversations using the ACP-capable contract.
151
-
152
- Deprecated since v1.22.0 and scheduled for removal in v1.27.0.
153
- Use ``/api/conversations`` instead.
154
- """
155
- assert len(ids) < 100
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
163
-
164
-
165
- @conversation_router_acp.post("", deprecated=True)
166
- async def start_acp_conversation(
167
- request: Annotated[
168
- StartACPConversationRequest,
169
- Body(examples=START_ACP_CONVERSATION_EXAMPLES),
170
- ],
171
- response: Response,
172
- include_skills: Annotated[bool, Query(title=INCLUDE_SKILLS_PARAM_TITLE)] = False,
173
- conversation_service: ConversationService = Depends(get_conversation_service),
174
- ) -> ACPConversationInfo:
175
- """Start a conversation using the ACP-capable contract.
176
-
177
- Deprecated since v1.22.0 and scheduled for removal in v1.27.0.
178
- Use ``/api/conversations`` instead; it now accepts ACP agents and
179
- ``agent_settings`` payloads.
180
- """
181
- info, is_new = await conversation_service.start_acp_conversation(request)
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)
185
- return info