openhands-agent-server 1.22.1__tar.gz → 1.23.1__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 (63) hide show
  1. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/PKG-INFO +1 -1
  2. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/api.py +27 -3
  3. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/bash_service.py +72 -2
  4. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/config.py +17 -2
  5. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/conversation_router.py +52 -1
  6. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/conversation_router_acp.py +27 -2
  7. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/conversation_service.py +19 -0
  8. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/event_service.py +113 -9
  9. openhands_agent_server-1.23.1/openhands/agent_server/mcp_router.py +225 -0
  10. openhands_agent_server-1.23.1/openhands/agent_server/middleware.py +94 -0
  11. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/models.py +54 -3
  12. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/persistence/__init__.py +14 -0
  13. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/persistence/models.py +49 -0
  14. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/persistence/store.py +107 -1
  15. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/server_details_router.py +10 -0
  16. openhands_agent_server-1.23.1/openhands/agent_server/skills_router.py +530 -0
  17. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/skills_service.py +298 -0
  18. openhands_agent_server-1.23.1/openhands/agent_server/workspaces_router.py +244 -0
  19. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands_agent_server.egg-info/PKG-INFO +1 -1
  20. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands_agent_server.egg-info/SOURCES.txt +4 -0
  21. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/pyproject.toml +1 -1
  22. openhands_agent_server-1.22.1/openhands/agent_server/middleware.py +0 -40
  23. openhands_agent_server-1.22.1/openhands/agent_server/skills_router.py +0 -192
  24. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/__init__.py +0 -0
  25. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/__main__.py +0 -0
  26. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/_secrets_exposure.py +0 -0
  27. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/auth_router.py +0 -0
  28. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/bash_router.py +0 -0
  29. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/cloud_proxy_router.py +0 -0
  30. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/conversation_lease.py +0 -0
  31. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/dependencies.py +0 -0
  32. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/desktop_router.py +0 -0
  33. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/desktop_service.py +0 -0
  34. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/docker/Dockerfile +0 -0
  35. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/docker/build.py +0 -0
  36. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/docker/wallpaper.svg +0 -0
  37. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/env_parser.py +0 -0
  38. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/event_router.py +0 -0
  39. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/file_router.py +0 -0
  40. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/git_router.py +0 -0
  41. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/hooks_router.py +0 -0
  42. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/hooks_service.py +0 -0
  43. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/llm_router.py +0 -0
  44. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/logging_config.py +0 -0
  45. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/openapi.py +0 -0
  46. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/profiles_router.py +0 -0
  47. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/pub_sub.py +0 -0
  48. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/py.typed +0 -0
  49. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/settings_router.py +0 -0
  50. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/sockets.py +0 -0
  51. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/tool_preload_service.py +0 -0
  52. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/tool_router.py +0 -0
  53. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/utils.py +0 -0
  54. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/vscode_extensions/openhands-settings/extension.js +0 -0
  55. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/vscode_extensions/openhands-settings/package.json +0 -0
  56. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/vscode_router.py +0 -0
  57. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/vscode_service.py +0 -0
  58. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/workspace_router.py +0 -0
  59. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands_agent_server.egg-info/dependency_links.txt +0 -0
  60. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands_agent_server.egg-info/entry_points.txt +0 -0
  61. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands_agent_server.egg-info/requires.txt +0 -0
  62. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands_agent_server.egg-info/top_level.txt +0 -0
  63. {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openhands-agent-server
3
- Version: 1.22.1
3
+ Version: 1.23.1
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,7 +3,7 @@ import os
3
3
  import tempfile
4
4
  import traceback
5
5
  from collections.abc import AsyncIterator, Sequence
6
- from contextlib import asynccontextmanager
6
+ from contextlib import asynccontextmanager, suppress
7
7
  from pathlib import Path
8
8
  from typing import Any
9
9
  from urllib.parse import urlparse
@@ -17,6 +17,7 @@ from starlette.requests import Request
17
17
 
18
18
  from openhands.agent_server.auth_router import auth_router
19
19
  from openhands.agent_server.bash_router import bash_router
20
+ from openhands.agent_server.bash_service import get_default_bash_event_service
20
21
  from openhands.agent_server.cloud_proxy_router import cloud_proxy_router
21
22
  from openhands.agent_server.config import (
22
23
  Config,
@@ -38,7 +39,8 @@ from openhands.agent_server.file_router import file_router
38
39
  from openhands.agent_server.git_router import git_router
39
40
  from openhands.agent_server.hooks_router import hooks_router
40
41
  from openhands.agent_server.llm_router import llm_router
41
- from openhands.agent_server.middleware import LocalhostCORSMiddleware
42
+ from openhands.agent_server.mcp_router import mcp_router
43
+ from openhands.agent_server.middleware import CORSDispatcher
42
44
  from openhands.agent_server.profiles_router import profiles_router
43
45
  from openhands.agent_server.server_details_router import (
44
46
  get_server_info,
@@ -53,6 +55,7 @@ from openhands.agent_server.tool_router import tool_router
53
55
  from openhands.agent_server.vscode_router import vscode_router
54
56
  from openhands.agent_server.vscode_service import get_vscode_service
55
57
  from openhands.agent_server.workspace_router import workspace_router
58
+ from openhands.agent_server.workspaces_router import workspaces_router
56
59
  from openhands.sdk.logger import DEBUG, get_logger
57
60
  from openhands.sdk.utils.redact import sanitize_dict
58
61
  from openhands.tools.terminal.constants import TMUX_SOCKET_NAME
@@ -186,9 +189,28 @@ async def api_lifespan(api: FastAPI) -> AsyncIterator[None]:
186
189
  async with service:
187
190
  # Store the initialized service in app state for dependency injection
188
191
  api.state.conversation_service = service
192
+
193
+ config = api.state.config
194
+ retention_task: asyncio.Task | None = None
195
+ if config.bash_events_retention_seconds is not None:
196
+ retention_task = asyncio.create_task(
197
+ get_default_bash_event_service().run_retention_cleanup_loop(
198
+ config.bash_events_retention_seconds
199
+ )
200
+ )
201
+ logger.info(
202
+ "Bash events retention cleanup started (retention: %ds)",
203
+ config.bash_events_retention_seconds,
204
+ )
205
+
189
206
  try:
190
207
  yield
191
208
  finally:
209
+ if retention_task is not None:
210
+ retention_task.cancel()
211
+ with suppress(asyncio.CancelledError):
212
+ await retention_task
213
+
192
214
  # Define async functions for stopping each service
193
215
  async def stop_vscode_service():
194
216
  if vscode_service is not None:
@@ -288,7 +310,9 @@ def _add_api_routes(app: FastAPI, config: Config) -> None:
288
310
  api_router.include_router(skills_router)
289
311
  api_router.include_router(hooks_router)
290
312
  api_router.include_router(llm_router)
313
+ api_router.include_router(mcp_router)
291
314
  api_router.include_router(settings_router)
315
+ api_router.include_router(workspaces_router)
292
316
  api_router.include_router(profiles_router)
293
317
  api_router.include_router(cloud_proxy_router)
294
318
  # /api/auth/* mints workspace cookies and requires the header to bootstrap,
@@ -515,7 +539,7 @@ def create_app(config: Config | None = None) -> FastAPI:
515
539
 
516
540
  _add_api_routes(app, config)
517
541
  _setup_static_files(app, config)
518
- app.add_middleware(LocalhostCORSMiddleware, allow_origins=config.allow_cors_origins)
542
+ app.add_middleware(CORSDispatcher, allow_origins=config.allow_cors_origins)
519
543
  _add_exception_handlers(app)
520
544
 
521
545
  return app
@@ -4,7 +4,7 @@ import json
4
4
  import os
5
5
  import signal
6
6
  from dataclasses import dataclass, field
7
- from datetime import datetime
7
+ from datetime import datetime, timedelta
8
8
  from pathlib import Path
9
9
  from uuid import UUID
10
10
 
@@ -18,7 +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.logger import get_logger
21
- from openhands.sdk.utils import sanitized_env
21
+ from openhands.sdk.utils import sanitized_env, utc_now
22
22
 
23
23
 
24
24
  logger = get_logger(__name__)
@@ -344,6 +344,76 @@ class BashEventService:
344
344
  self._save_event_to_file(error_output)
345
345
  await self._pub_sub(error_output)
346
346
 
347
+ def delete_events_older_than(self, cutoff: datetime) -> int:
348
+ """Delete bash event files with a recorded timestamp older than ``cutoff``.
349
+
350
+ This is a synchronous method — all operations are blocking filesystem
351
+ I/O. Callers on the asyncio event loop should use
352
+ ``await asyncio.to_thread(service.delete_events_older_than, cutoff)``
353
+ to avoid stalling the loop.
354
+
355
+ File names are prefixed with ``YYYYMMDDHHMMSS`` in ascending sort order,
356
+ so scanning stops as soon as a file at or after the cutoff is reached.
357
+
358
+ Returns:
359
+ int: The number of event files deleted.
360
+ """
361
+ cutoff_str = self._timestamp_to_str(cutoff)
362
+ files = self._get_event_files_by_pattern("*") # ascending chronological order
363
+ count = 0
364
+ for path in files:
365
+ if path.name >= cutoff_str:
366
+ break # remaining files are at or newer than cutoff
367
+ try:
368
+ path.unlink(missing_ok=True)
369
+ count += 1
370
+ except Exception as e:
371
+ logger.warning("Failed to delete bash event file %s: %s", path, e)
372
+ if count:
373
+ logger.info(
374
+ "Deleted %d bash event file(s) older than %s", count, cutoff_str
375
+ )
376
+ return count
377
+
378
+ async def run_retention_cleanup_loop(
379
+ self,
380
+ retention_seconds: int,
381
+ interval_seconds: float | None = None,
382
+ ) -> None:
383
+ """Periodically purge bash event files older than ``retention_seconds``.
384
+
385
+ Runs until cancelled (e.g. during application shutdown). Cleanup runs
386
+ immediately on entry so that files accumulated across a server restart
387
+ are purged without waiting for the first interval to elapse.
388
+
389
+ Blocking filesystem work is dispatched to a thread via
390
+ ``asyncio.to_thread`` to keep the event loop free.
391
+
392
+ Args:
393
+ retention_seconds: Age threshold in seconds; older files are deleted.
394
+ interval_seconds: How often to run the cleanup. Defaults to
395
+ ``max(60, retention_seconds / 2)``. Pass a smaller value in
396
+ tests to avoid long waits.
397
+ """
398
+ interval = (
399
+ interval_seconds
400
+ if interval_seconds is not None
401
+ else max(60.0, retention_seconds / 2)
402
+ )
403
+ while True:
404
+ try:
405
+ cutoff = utc_now() - timedelta(seconds=retention_seconds)
406
+ await asyncio.to_thread(self.delete_events_older_than, cutoff)
407
+ except Exception as e:
408
+ logger.warning("Bash events retention cleanup error: %s", e)
409
+ # Brief back-off to prevent log flooding if the failure is persistent
410
+ # (e.g. permission error, full disk). Cap at the normal interval so
411
+ # we don't over-delay in low-retention configurations.
412
+ await asyncio.sleep(min(interval, 60.0))
413
+ # Always sleep the full interval after the error back-off, so total
414
+ # wait on error = min(interval, 60) + interval ≈ 2× normal cadence.
415
+ await asyncio.sleep(interval)
416
+
347
417
  async def subscribe_to_events(self, subscriber: Subscriber[BashEventBase]) -> UUID:
348
418
  """Subscribe to bash events.
349
419
 
@@ -120,8 +120,10 @@ class Config(BaseModel):
120
120
  allow_cors_origins: list[str] = Field(
121
121
  default_factory=list,
122
122
  description=(
123
- "Set of CORS origins permitted by this server (Anything from localhost is "
124
- "always accepted regardless of what's in here)."
123
+ "CORS origins permitted by this server. Localhost / 127.0.0.1 "
124
+ "and ``DOCKER_HOST_ADDR`` are always allowed. Does not apply to "
125
+ "the workspace cookie routes, which accept any origin — see "
126
+ "``middleware.py``."
125
127
  ),
126
128
  )
127
129
  conversations_path: Path = Field(
@@ -137,6 +139,19 @@ class Config(BaseModel):
137
139
  "Defaults to 'workspace/bash_events'."
138
140
  ),
139
141
  )
142
+ bash_events_retention_seconds: int | None = Field(
143
+ default=None,
144
+ gt=0,
145
+ description=(
146
+ "How long bash event files are retained on disk, in seconds. "
147
+ "A background task purges events older than this window on a "
148
+ "rolling basis. None (default) retains events indefinitely. "
149
+ "Should be set higher than the longest expected command timeout: "
150
+ "a command whose BashCommand file is purged mid-execution will "
151
+ "complete normally, but its on-disk event history will be "
152
+ "incomplete. A value >= 2x max command timeout avoids this."
153
+ ),
154
+ )
140
155
  static_files_path: Path | None = Field(
141
156
  default=None,
142
157
  description=(
@@ -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
- return await conversation_service.search_conversations(
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
- return await conversation_service.search_acp_conversations(
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
- return await conversation_service.batch_get_acp_conversations(ids)
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
@@ -67,6 +68,9 @@ class EventService:
67
68
  default_factory=lambda: PubSub[Event](max_subscribers=50), init=False
68
69
  )
69
70
  _run_task: asyncio.Task | None = field(default=None, init=False)
71
+ # Set when a send_message(run=True) is rejected because a run is still
72
+ # wrapping up; consumed by _run_and_publish to re-run the stranded message.
73
+ _rerun_requested: bool = field(default=False, init=False)
70
74
  _run_lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False)
71
75
  _callback_wrapper: AsyncCallbackWrapper | None = field(default=None, init=False)
72
76
  _lease: ConversationLease | None = field(default=None, init=False)
@@ -418,9 +422,19 @@ class EventService:
418
422
  loop = asyncio.get_running_loop()
419
423
  await loop.run_in_executor(None, self._conversation.send_message, message)
420
424
  if run:
421
- # Already running or inactive — message was sent, skip run.
422
- with suppress(ValueError):
425
+ try:
423
426
  await self.run()
427
+ except ValueError as e:
428
+ # run() refused. If a run is still wrapping up (its
429
+ # wait_for_pending tail), the message we just appended won't be
430
+ # picked up by it, so record explicit run intent for
431
+ # _run_and_publish to honor once that task clears. Tracking the
432
+ # request — rather than inferring it later from an IDLE status —
433
+ # is what keeps a deliberate run=False append, or an IDLE reached
434
+ # via another path, from triggering an unwanted run.
435
+ # "inactive_service" is terminal and must not re-arm.
436
+ if str(e) == "conversation_already_running":
437
+ self._rerun_requested = True
424
438
 
425
439
  async def subscribe_to_events(self, subscriber: Subscriber[Event]) -> UUID:
426
440
  subscriber_id = self._pub_sub.subscribe(subscriber)
@@ -522,13 +536,29 @@ class EventService:
522
536
  """Configure stats update callbacks to stream stats changes via events."""
523
537
 
524
538
  def stats_callback() -> None:
525
- """Callback to emit stats updates."""
539
+ """Callback to emit stats updates.
540
+
541
+ Invoked synchronously by ``Telemetry.on_response`` (regular
542
+ Agent path) and ``ACPAgent._record_usage`` (ACP path) — both
543
+ run inside ``LocalConversation.run()``'s ``with self._state:``
544
+ block, so the caller already owns the conversation state lock.
545
+
546
+ DO NOT re-acquire the state lock here (``with state:``). It
547
+ looks safe — ``FIFOLock`` documents itself as reentrant — but
548
+ on the ACP code path it deadlocks (silently) before the rest
549
+ of ``step()`` can emit the assistant's FinishAction +
550
+ ObservationEvent, leaving every conversation hung in
551
+ ``running`` status forever. ``_emit_event_from_thread`` below
552
+ already acquires the lock on the executor thread before
553
+ persisting the event; that's the only place serialization
554
+ needs the lock anyway.
555
+ """
526
556
  # Publish only the stats field to avoid sending entire state
527
557
  if not self._conversation:
528
558
  return
529
- state = self._conversation._state
530
- with state:
531
- event = ConversationStateUpdateEvent(key="stats", value=state.stats)
559
+ event = ConversationStateUpdateEvent(
560
+ key="stats", value=self._conversation._state.stats
561
+ )
532
562
  self._emit_event_from_thread(event)
533
563
 
534
564
  for llm in agent.get_all_llms():
@@ -645,6 +675,7 @@ class EventService:
645
675
  cipher=self.cipher,
646
676
  hook_config=self.stored.hook_config,
647
677
  tags=self.stored.tags,
678
+ user_id=self.stored.user_id,
648
679
  )
649
680
 
650
681
  conversation.set_confirmation_policy(self.stored.confirmation_policy)
@@ -706,8 +737,11 @@ class EventService:
706
737
  """Run the conversation asynchronously in the background.
707
738
 
708
739
  This method starts the conversation run in a background task and returns
709
- immediately. The conversation status can be monitored via the
710
- GET /api/conversations/{id} endpoint or WebSocket events.
740
+ immediately. When possible, the conversation is driven via its native
741
+ ``arun()`` coroutine so LLM I/O does not tie up a thread-pool worker.
742
+ For conversations that do not expose ``arun()`` (e.g., custom
743
+ subclasses), the synchronous ``run()`` is executed in the thread pool as
744
+ before.
711
745
 
712
746
  Raises:
713
747
  ValueError: If the service is inactive or conversation is already running.
@@ -735,7 +769,28 @@ class EventService:
735
769
 
736
770
  async def _run_and_publish():
737
771
  try:
738
- await loop.run_in_executor(self._run_executor, conversation.run)
772
+ # Prefer the native async path when available so the event
773
+ # loop is free during LLM I/O. Fall back to thread-pool
774
+ # execution for backward compatibility.
775
+ #
776
+ # Both guards are required:
777
+ # • iscoroutinefunction – filters out non-async objects
778
+ # (e.g. MagicMock in tests).
779
+ # • override check – BaseConversation defines a default
780
+ # ``async def arun()`` that delegates to sync ``run()``,
781
+ # so iscoroutinefunction alone is always True for real
782
+ # subclasses. We detect an *actual* override to avoid
783
+ # running a sync-only subclass on the event loop.
784
+ arun = getattr(conversation, "arun", None)
785
+ has_native_arun = (
786
+ arun is not None
787
+ and asyncio.iscoroutinefunction(arun)
788
+ and type(conversation).arun is not BaseConversation.arun
789
+ )
790
+ if has_native_arun:
791
+ await conversation.arun()
792
+ else:
793
+ await loop.run_in_executor(self._run_executor, conversation.run)
739
794
  except Exception:
740
795
  logger.exception("Error during conversation run")
741
796
  finally:
@@ -753,6 +808,26 @@ class EventService:
753
808
  self._run_task = None
754
809
  await self._publish_state_update()
755
810
 
811
+ # Re-arm a run for input stranded while this task was
812
+ # wrapping up. A send_message(run=True) that arrived during
813
+ # the wait_for_pending() tail above had its run() rejected as
814
+ # "conversation_already_running" and suppressed, setting
815
+ # _rerun_requested. Honor it only while the conversation is
816
+ # still IDLE — i.e. that message is genuinely pending. If the
817
+ # run loop was still alive it already absorbed the message
818
+ # (LocalConversation.run() keeps looping on FINISHED) and we
819
+ # are FINISHED here, so the IDLE guard avoids a redundant run.
820
+ # A deliberate run=False append, or an IDLE reached via
821
+ # another path, never sets the flag.
822
+ if self._rerun_requested:
823
+ self._rerun_requested = False
824
+ if (
825
+ await self._get_execution_status()
826
+ == ConversationExecutionStatus.IDLE
827
+ ):
828
+ with suppress(ValueError):
829
+ await self.run()
830
+
756
831
  # Create task but don't await it - runs in background
757
832
  self._run_task = asyncio.create_task(_run_and_publish())
758
833
 
@@ -787,6 +862,28 @@ class EventService:
787
862
  # Publish state update after pause to ensure stats are updated
788
863
  await self._publish_state_update()
789
864
 
865
+ async def interrupt(self):
866
+ """Immediately cancel an in-flight async LLM call.
867
+
868
+ Delegates to :meth:`LocalConversation.interrupt` which cancels the
869
+ ``arun()`` task. If no async run is in progress the call falls
870
+ back to :meth:`pause`.
871
+ """
872
+ if self._conversation:
873
+ self._conversation.interrupt()
874
+ # Wait for the run task to finish so we can publish the final
875
+ # state update (PAUSED + InterruptEvent) cleanly.
876
+ if self._run_task is not None and not self._run_task.done():
877
+ with suppress(Exception):
878
+ await asyncio.wait_for(self._run_task, timeout=5.0)
879
+ # Only clear _run_task if it actually finished; if
880
+ # wait_for timed out the task may still be running and
881
+ # clearing prematurely would allow a second run() to
882
+ # start while the first is still in progress.
883
+ if self._run_task is not None and self._run_task.done():
884
+ self._run_task = None
885
+ await self._publish_state_update()
886
+
790
887
  async def update_secrets(self, secrets: dict[str, SecretValue]):
791
888
  """Update secrets in the conversation."""
792
889
  if not self._conversation:
@@ -832,8 +929,15 @@ class EventService:
832
929
  logger.warning(
833
930
  "Failed to pause conversation during close", exc_info=True
834
931
  )
932
+ # Cancel the run task so arun()'s CancelledError handler can
933
+ # transition to PAUSED cleanly. For the legacy thread-pool
934
+ # path the underlying thread keeps running but the wrapper
935
+ # task still settles, unblocking the wait below.
936
+ self._run_task.cancel()
835
937
  try:
836
938
  await asyncio.wait_for(self._run_task, timeout=10.0)
939
+ except asyncio.CancelledError:
940
+ pass # Expected after cancel()
837
941
  except Exception as exc:
838
942
  logger.warning("Run task did not exit cleanly during close: %s", exc)
839
943
  self._run_task = None