openhands-agent-server 1.23.0__tar.gz → 1.24.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 (62) hide show
  1. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/PKG-INFO +1 -1
  2. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/api.py +23 -3
  3. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/bash_service.py +72 -2
  4. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/config.py +17 -2
  5. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/conversation_router.py +46 -0
  6. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/conversation_service.py +56 -0
  7. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/event_service.py +92 -14
  8. openhands_agent_server-1.24.0/openhands/agent_server/middleware.py +94 -0
  9. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/models.py +49 -0
  10. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands_agent_server.egg-info/PKG-INFO +1 -1
  11. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/pyproject.toml +1 -1
  12. openhands_agent_server-1.23.0/openhands/agent_server/middleware.py +0 -40
  13. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/__init__.py +0 -0
  14. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/__main__.py +0 -0
  15. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/_secrets_exposure.py +0 -0
  16. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/auth_router.py +0 -0
  17. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/bash_router.py +0 -0
  18. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/cloud_proxy_router.py +0 -0
  19. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/conversation_lease.py +0 -0
  20. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/conversation_router_acp.py +0 -0
  21. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/dependencies.py +0 -0
  22. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/desktop_router.py +0 -0
  23. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/desktop_service.py +0 -0
  24. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/docker/Dockerfile +0 -0
  25. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/docker/build.py +0 -0
  26. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/docker/wallpaper.svg +0 -0
  27. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/env_parser.py +0 -0
  28. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/event_router.py +0 -0
  29. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/file_router.py +0 -0
  30. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/git_router.py +0 -0
  31. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/hooks_router.py +0 -0
  32. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/hooks_service.py +0 -0
  33. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/llm_router.py +0 -0
  34. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/logging_config.py +0 -0
  35. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/mcp_router.py +0 -0
  36. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/openapi.py +0 -0
  37. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/persistence/__init__.py +0 -0
  38. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/persistence/models.py +0 -0
  39. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/persistence/store.py +0 -0
  40. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/profiles_router.py +0 -0
  41. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/pub_sub.py +0 -0
  42. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/py.typed +0 -0
  43. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/server_details_router.py +0 -0
  44. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/settings_router.py +0 -0
  45. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/skills_router.py +0 -0
  46. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/skills_service.py +0 -0
  47. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/sockets.py +0 -0
  48. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/tool_preload_service.py +0 -0
  49. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/tool_router.py +0 -0
  50. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/utils.py +0 -0
  51. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/vscode_extensions/openhands-settings/extension.js +0 -0
  52. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/vscode_extensions/openhands-settings/package.json +0 -0
  53. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/vscode_router.py +0 -0
  54. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/vscode_service.py +0 -0
  55. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/workspace_router.py +0 -0
  56. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/workspaces_router.py +0 -0
  57. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands_agent_server.egg-info/SOURCES.txt +0 -0
  58. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands_agent_server.egg-info/dependency_links.txt +0 -0
  59. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands_agent_server.egg-info/entry_points.txt +0 -0
  60. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands_agent_server.egg-info/requires.txt +0 -0
  61. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands_agent_server.egg-info/top_level.txt +0 -0
  62. {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openhands-agent-server
3
- Version: 1.23.0
3
+ Version: 1.24.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
@@ -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,
@@ -39,7 +40,7 @@ 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
42
  from openhands.agent_server.mcp_router import mcp_router
42
- from openhands.agent_server.middleware import LocalhostCORSMiddleware
43
+ from openhands.agent_server.middleware import CORSDispatcher
43
44
  from openhands.agent_server.profiles_router import profiles_router
44
45
  from openhands.agent_server.server_details_router import (
45
46
  get_server_info,
@@ -188,9 +189,28 @@ async def api_lifespan(api: FastAPI) -> AsyncIterator[None]:
188
189
  async with service:
189
190
  # Store the initialized service in app state for dependency injection
190
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
+
191
206
  try:
192
207
  yield
193
208
  finally:
209
+ if retention_task is not None:
210
+ retention_task.cancel()
211
+ with suppress(asyncio.CancelledError):
212
+ await retention_task
213
+
194
214
  # Define async functions for stopping each service
195
215
  async def stop_vscode_service():
196
216
  if vscode_service is not None:
@@ -519,7 +539,7 @@ def create_app(config: Config | None = None) -> FastAPI:
519
539
 
520
540
  _add_api_routes(app, config)
521
541
  _setup_static_files(app, config)
522
- app.add_middleware(LocalhostCORSMiddleware, allow_origins=config.allow_cors_origins)
542
+ app.add_middleware(CORSDispatcher, allow_origins=config.allow_cors_origins)
523
543
  _add_exception_handlers(app)
524
544
 
525
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=(
@@ -394,6 +394,52 @@ async def switch_conversation_llm(
394
394
  return Success()
395
395
 
396
396
 
397
+ @conversation_router.post(
398
+ "/{conversation_id}/switch_acp_model",
399
+ responses={
400
+ 400: {"description": "Agent is not ACP, or provider can't switch models"},
401
+ 404: {"description": "Conversation not found"},
402
+ 409: {"description": "ACP session not initialized yet"},
403
+ 504: {"description": "ACP server did not answer the model switch in time"},
404
+ },
405
+ )
406
+ async def switch_conversation_acp_model(
407
+ conversation_id: UUID,
408
+ model: str = Body(..., embed=True),
409
+ conversation_service: ConversationService = Depends(get_conversation_service),
410
+ ) -> Success:
411
+ """Switch the model of a running ACP conversation, mid-conversation.
412
+
413
+ Issues a protocol-level ``session/set_model`` call to the ACP subprocess
414
+ so the new model applies to subsequent turns without losing context. Only
415
+ valid for ACP conversations whose provider supports runtime switching.
416
+ """
417
+ event_service = await conversation_service.get_event_service(conversation_id)
418
+ if event_service is None:
419
+ raise HTTPException(status.HTTP_404_NOT_FOUND)
420
+ try:
421
+ await event_service.switch_acp_model(model)
422
+ except ValueError as e:
423
+ raise HTTPException(
424
+ status_code=status.HTTP_400_BAD_REQUEST,
425
+ detail=str(e),
426
+ )
427
+ except TimeoutError as e:
428
+ # The bounded session/set_model round-trip expired. The ACP server is
429
+ # wedged/slow rather than rejecting the request, so surface a 504
430
+ # instead of an opaque 500.
431
+ raise HTTPException(
432
+ status_code=status.HTTP_504_GATEWAY_TIMEOUT,
433
+ detail=str(e),
434
+ )
435
+ except RuntimeError as e:
436
+ raise HTTPException(
437
+ status_code=status.HTTP_409_CONFLICT,
438
+ detail=str(e),
439
+ )
440
+ return Success()
441
+
442
+
397
443
  @conversation_router.patch(
398
444
  "/{conversation_id}", responses={404: {"description": "Item not found"}}
399
445
  )
@@ -245,12 +245,68 @@ def _compose_conversation_info(
245
245
  # Use mode='json' so SecretStr in nested structures (e.g. LookupSecret.headers,
246
246
  # agent.agent_context.secrets) serialize to strings. Without it, validation
247
247
  # fails because ConversationInfo expects dict[str, str] but receives SecretStr.
248
+ #
249
+ # ACP model state is lifted onto top-level ConversationInfo fields because
250
+ # the agent holds it in PrivateAttrs (ACPAgent is frozen) which don't survive
251
+ # ``model_dump``. ``getattr`` keeps non-ACP agents a no-op. We read the live
252
+ # agent (fresh within a session) and fall back to ``state.agent_state`` —
253
+ # persisted to ``base_state.json`` by ``ACPAgent._init`` (and kept in sync by
254
+ # ``switch_acp_model``) — so cold list reads, where PrivateAttrs are still
255
+ # empty because ``init_state`` hasn't fired, still surface the last-known
256
+ # state. Persisted ``acp_available_models`` is a list of dicts that
257
+ # ``ConversationInfo`` coerces back into ``ACPModelInfo``.
258
+ agent_state = getattr(state, "agent_state", {}) or {}
259
+ agent = state.agent
260
+ # current_model_id: live PrivateAttr (fresh after a runtime switch) → the
261
+ # persisted hint → the authoritative ``acp_model`` the agent runs on resume.
262
+ #
263
+ # The ``acp_model`` fallback is gated on the agent NOT being a live,
264
+ # initialized one. Once ``init_state`` has fired, ``current_model_id`` is the
265
+ # authoritative resolved value — including ``None`` when an override couldn't
266
+ # be applied (unknown provider, or a resume whose ``set_session_model`` the
267
+ # server rejected) — so falling back to ``acp_model`` there would re-assert an
268
+ # override the live session isn't actually running. The fallback is only for
269
+ # *cold* reads (``init_state`` hasn't fired, PrivateAttrs still empty), where
270
+ # the serialized ``acp_model`` is the best last-known hint. The persisted
271
+ # ``acp_current_model_id`` hint is kept honest by ``ACPAgent.init_state`` (it
272
+ # clears the value whenever the override wasn't applied), so it's safe in
273
+ # both cases.
274
+ agent_initialized = bool(getattr(agent, "_initialized", False))
275
+ current_model_id = (
276
+ getattr(agent, "current_model_id", None)
277
+ or agent_state.get("acp_current_model_id")
278
+ or (None if agent_initialized else getattr(agent, "acp_model", None))
279
+ )
280
+ # available_models: the property returns ``[]`` (never ``None``) for *both* a
281
+ # cold-read agent (PrivateAttr default, init_state hasn't fired) and a live
282
+ # agent that genuinely has no models, so an ``is None`` check can't tell them
283
+ # apart — and would drop the persisted picker payload on every cold list
284
+ # read. The ``or`` chain is deliberate: an empty live list falls back to the
285
+ # persisted snapshot, which is exactly right on cold reads (surface the
286
+ # last-known list) and benign for a live empty session (the persisted value
287
+ # is itself empty/absent there).
288
+ available_models = (
289
+ getattr(agent, "available_models", None)
290
+ or agent_state.get("acp_available_models")
291
+ or []
292
+ )
293
+ # Static provider capability. Unlike the two fields above it has no
294
+ # meaningful live-vs-persisted distinction — it's derived from the stable
295
+ # provider identity and written once at session init — so we read the
296
+ # persisted value directly. Defaults False for non-ACP agents and
297
+ # conversations that haven't started a session.
298
+ supports_runtime_model_switch = bool(
299
+ agent_state.get("acp_supports_runtime_model_switch", False)
300
+ )
248
301
  return ConversationInfo(
249
302
  **state.model_dump(mode="json"),
250
303
  title=stored.title,
251
304
  metrics=stored.metrics,
252
305
  created_at=stored.created_at,
253
306
  updated_at=stored.updated_at,
307
+ current_model_id=current_model_id,
308
+ available_models=available_models,
309
+ supports_runtime_model_switch=supports_runtime_model_switch,
254
310
  )
255
311
 
256
312
 
@@ -68,6 +68,9 @@ class EventService:
68
68
  default_factory=lambda: PubSub[Event](max_subscribers=50), init=False
69
69
  )
70
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)
71
74
  _run_lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False)
72
75
  _callback_wrapper: AsyncCallbackWrapper | None = field(default=None, init=False)
73
76
  _lease: ConversationLease | None = field(default=None, init=False)
@@ -419,9 +422,19 @@ class EventService:
419
422
  loop = asyncio.get_running_loop()
420
423
  await loop.run_in_executor(None, self._conversation.send_message, message)
421
424
  if run:
422
- # Already running or inactive — message was sent, skip run.
423
- with suppress(ValueError):
425
+ try:
424
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
425
438
 
426
439
  async def subscribe_to_events(self, subscriber: Subscriber[Event]) -> UUID:
427
440
  subscriber_id = self._pub_sub.subscribe(subscriber)
@@ -523,13 +536,29 @@ class EventService:
523
536
  """Configure stats update callbacks to stream stats changes via events."""
524
537
 
525
538
  def stats_callback() -> None:
526
- """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
+ """
527
556
  # Publish only the stats field to avoid sending entire state
528
557
  if not self._conversation:
529
558
  return
530
- state = self._conversation._state
531
- with state:
532
- event = ConversationStateUpdateEvent(key="stats", value=state.stats)
559
+ event = ConversationStateUpdateEvent(
560
+ key="stats", value=self._conversation._state.stats
561
+ )
533
562
  self._emit_event_from_thread(event)
534
563
 
535
564
  for llm in agent.get_all_llms():
@@ -646,6 +675,7 @@ class EventService:
646
675
  cipher=self.cipher,
647
676
  hook_config=self.stored.hook_config,
648
677
  tags=self.stored.tags,
678
+ user_id=self.stored.user_id,
649
679
  )
650
680
 
651
681
  conversation.set_confirmation_policy(self.stored.confirmation_policy)
@@ -710,8 +740,9 @@ class EventService:
710
740
  immediately. When possible, the conversation is driven via its native
711
741
  ``arun()`` coroutine so LLM I/O does not tie up a thread-pool worker.
712
742
  For conversations that do not expose ``arun()`` (e.g., custom
713
- subclasses), the synchronous ``run()`` is executed in the thread pool as
714
- before.
743
+ subclasses) or whose agent only implements sync ``step()`` (no
744
+ ``astep()`` override), the synchronous ``run()`` is executed
745
+ in the thread pool as before.
715
746
 
716
747
  Raises:
717
748
  ValueError: If the service is inactive or conversation is already running.
@@ -743,19 +774,23 @@ class EventService:
743
774
  # loop is free during LLM I/O. Fall back to thread-pool
744
775
  # execution for backward compatibility.
745
776
  #
746
- # Both guards are required:
777
+ # All guards are required:
747
778
  # • iscoroutinefunction – filters out non-async objects
748
779
  # (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.
780
+ # • conversation override – BaseConversation's default
781
+ # ``arun()`` delegates to sync ``run()``, so we require an
782
+ # *actual* override to avoid running a sync-only subclass
783
+ # on the event loop.
784
+ # agent override ``LocalConversation`` always overrides
785
+ # ``arun()``, but an agent without an ``astep()`` override
786
+ # runs sync ``step()`` in a worker thread; route it
787
+ # through sync ``run()`` instead.
754
788
  arun = getattr(conversation, "arun", None)
755
789
  has_native_arun = (
756
790
  arun is not None
757
791
  and asyncio.iscoroutinefunction(arun)
758
792
  and type(conversation).arun is not BaseConversation.arun
793
+ and type(conversation.agent).astep is not AgentBase.astep
759
794
  )
760
795
  if has_native_arun:
761
796
  await conversation.arun()
@@ -778,6 +813,26 @@ class EventService:
778
813
  self._run_task = None
779
814
  await self._publish_state_update()
780
815
 
816
+ # Re-arm a run for input stranded while this task was
817
+ # wrapping up. A send_message(run=True) that arrived during
818
+ # the wait_for_pending() tail above had its run() rejected as
819
+ # "conversation_already_running" and suppressed, setting
820
+ # _rerun_requested. Honor it only while the conversation is
821
+ # still IDLE — i.e. that message is genuinely pending. If the
822
+ # run loop was still alive it already absorbed the message
823
+ # (LocalConversation.run() keeps looping on FINISHED) and we
824
+ # are FINISHED here, so the IDLE guard avoids a redundant run.
825
+ # A deliberate run=False append, or an IDLE reached via
826
+ # another path, never sets the flag.
827
+ if self._rerun_requested:
828
+ self._rerun_requested = False
829
+ if (
830
+ await self._get_execution_status()
831
+ == ConversationExecutionStatus.IDLE
832
+ ):
833
+ with suppress(ValueError):
834
+ await self.run()
835
+
781
836
  # Create task but don't await it - runs in background
782
837
  self._run_task = asyncio.create_task(_run_and_publish())
783
838
 
@@ -861,6 +916,29 @@ class EventService:
861
916
  None, self._conversation.set_security_analyzer, security_analyzer
862
917
  )
863
918
 
919
+ async def switch_acp_model(self, model: str) -> None:
920
+ """Switch the model on a running ACP conversation, mid-conversation.
921
+
922
+ Runs the (blocking) protocol-level ``session/set_model`` round-trip in a
923
+ worker thread, then mirrors the new model into ``meta.json`` so the
924
+ switch survives an agent-server restart: ``start()`` rebuilds the agent
925
+ from ``self.stored.agent`` and ``ConversationState.create()`` copies
926
+ that over the persisted base_state.json on resume. Only ``acp_model``
927
+ needs updating — ``model_post_init`` re-derives the sentinel
928
+ ``llm.model`` on reload.
929
+ """
930
+ if self._conversation is None:
931
+ raise RuntimeError(
932
+ "Conversation is not active; it has not been started or has "
933
+ "been closed."
934
+ )
935
+ loop = asyncio.get_running_loop()
936
+ await loop.run_in_executor(None, self._conversation.switch_acp_model, model)
937
+ self.stored = self.stored.model_copy(
938
+ update={"agent": self.stored.agent.model_copy(update={"acp_model": model})}
939
+ )
940
+ await self.save_meta()
941
+
864
942
  async def close(self):
865
943
  if self._lease_task is not None:
866
944
  self._lease_task.cancel()
@@ -0,0 +1,94 @@
1
+ """CORS middleware for the agent server.
2
+
3
+ ``CORSDispatcher`` routes requests to one of two CORS configurations
4
+ based on path:
5
+
6
+ * Workspace cookie endpoints (``/api/auth/workspace-session`` and
7
+ ``/api/conversations/{id}/workspace/*``) — wildcard CORS that echoes
8
+ the request Origin on every response. These are the only routes that
9
+ authenticate via an ambient (cookie) credential.
10
+ * Everything else — ``LocalhostCORSMiddleware``, which honors the
11
+ operator's ``allow_cors_origins`` and always allows localhost and
12
+ ``DOCKER_HOST_ADDR`` (matches OpenHands/OpenHands#4624 intent).
13
+ """
14
+
15
+ import os
16
+ import re
17
+ from urllib.parse import urlparse
18
+
19
+ from fastapi.middleware.cors import CORSMiddleware
20
+ from starlette.types import ASGIApp, Receive, Scope, Send
21
+
22
+
23
+ _WORKSPACE_SESSION_PATH = "/api/auth/workspace-session"
24
+ _WORKSPACE_STATIC_RE = re.compile(r"^/api/conversations/[^/]+/workspace(/|$)")
25
+
26
+
27
+ def _is_workspace_cookie_path(path: str) -> bool:
28
+ if path == _WORKSPACE_SESSION_PATH:
29
+ return True
30
+ return bool(_WORKSPACE_STATIC_RE.match(path))
31
+
32
+
33
+ class LocalhostCORSMiddleware(CORSMiddleware):
34
+ """``CORSMiddleware`` that always allows localhost and ``DOCKER_HOST_ADDR``."""
35
+
36
+ def __init__(self, app: ASGIApp, allow_origins: list[str]) -> None:
37
+ super().__init__(
38
+ app,
39
+ allow_origins=allow_origins,
40
+ allow_credentials=True,
41
+ allow_methods=["*"],
42
+ allow_headers=["*"],
43
+ )
44
+
45
+ def is_allowed_origin(self, origin: str) -> bool:
46
+ if origin:
47
+ hostname = urlparse(origin).hostname or ""
48
+ if hostname in ("localhost", "127.0.0.1"):
49
+ return True
50
+ docker_host_addr = os.environ.get("DOCKER_HOST_ADDR")
51
+ if docker_host_addr and hostname == docker_host_addr:
52
+ return True
53
+ return bool(super().is_allowed_origin(origin))
54
+
55
+
56
+ class CORSDispatcher:
57
+ """Dispatches each request to the workspace or default CORS middleware.
58
+
59
+ The workspace branch uses ``allow_origin_regex=r"https?://.+"`` rather
60
+ than ``allow_origins=["*"]`` for two reasons:
61
+
62
+ 1. Starlette emits a literal ``*`` on simple responses when
63
+ ``allow_all_origins`` is set and the request has no ``Cookie``
64
+ header — which browsers reject together with
65
+ ``Access-Control-Allow-Credentials: true``. The regex path always
66
+ echoes the request Origin (with ``Vary: Origin``).
67
+ 2. Anchoring to ``http(s)://`` excludes ``Origin: null`` (sandboxed
68
+ iframes, ``data:`` / ``blob:`` URLs), which have no defined CHIPS
69
+ partition key and are not legitimate clients.
70
+ """
71
+
72
+ def __init__(self, app: ASGIApp, *, allow_origins: list[str]) -> None:
73
+ self._default_cors = LocalhostCORSMiddleware(
74
+ app, allow_origins=list(allow_origins)
75
+ )
76
+ self._workspace_cors = CORSMiddleware(
77
+ app,
78
+ allow_origin_regex=r"https?://.+",
79
+ allow_credentials=True,
80
+ allow_methods=["*"],
81
+ allow_headers=["*"],
82
+ )
83
+
84
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
85
+ if scope.get("type") == "http":
86
+ # Strip FastAPI ``root_path`` so dispatch works behind
87
+ # reverse proxies mounted under a sub-path.
88
+ root_path = scope.get("root_path", "")
89
+ path = scope.get("path", "/")
90
+ route_path = path.removeprefix(root_path) if root_path else path
91
+ if _is_workspace_cookie_path(route_path or "/"):
92
+ await self._workspace_cors(scope, receive, send)
93
+ return
94
+ await self._default_cors(scope, receive, send)
@@ -9,6 +9,7 @@ from uuid import UUID, uuid4
9
9
  from pydantic import BaseModel, Field, field_validator
10
10
 
11
11
  from openhands.sdk import LLM
12
+ from openhands.sdk.agent.acp_models import ACPModelInfo
12
13
  from openhands.sdk.agent.base import AgentBase
13
14
  from openhands.sdk.conversation.conversation_stats import ConversationStats
14
15
  from openhands.sdk.conversation.request import ( # re-export for backward compat
@@ -184,6 +185,54 @@ class _ConversationInfoBase(BaseModel):
184
185
  "alphanumeric. Values are arbitrary strings up to 256 characters."
185
186
  ),
186
187
  )
188
+ current_model_id: str | None = Field(
189
+ default=None,
190
+ description=(
191
+ "Model the agent is actually using for this session. For ACP "
192
+ "agents, this is lifted off ``ACPAgent.current_model_id`` "
193
+ "(populated from the ``models.currentModelId`` field on the "
194
+ "ACP session response, or from ``acp_model`` when the caller "
195
+ "forced an override). May be an opaque alias (e.g. "
196
+ 'claude-agent-acp\'s ``"default"``); match it against '
197
+ "``available_models`` to get a display label. ``None`` for older "
198
+ "ACP servers that don't surface the field, or while the agent is "
199
+ "still initializing. Native OpenHands agents leave this ``None`` — "
200
+ "consumers should read ``agent.llm.model`` for those."
201
+ ),
202
+ )
203
+ available_models: list[ACPModelInfo] = Field(
204
+ default_factory=list,
205
+ description=(
206
+ "Models the ACP server offers for this session, lifted off "
207
+ "``ACPAgent.available_models`` (the ``models.availableModels`` "
208
+ "field on the ACP session response). Each entry carries a "
209
+ "``model_id`` plus an optional ``name``/``description``. Surfaced "
210
+ "verbatim so clients can render a model picker and resolve "
211
+ "``current_model_id`` to a display label themselves — the server "
212
+ "does no name curation. Empty for ACP servers that don't surface "
213
+ "the (UNSTABLE) capability and for native OpenHands agents. "
214
+ "Client contract: ``current_model_id`` is NOT guaranteed to be a "
215
+ "member — a forced ``acp_model`` override may name a model absent "
216
+ "from the list — so treat a miss as 'show the raw id'. Some "
217
+ "entries are opaque aliases whose human identity lives in "
218
+ '``description`` (e.g. claude-agent-acp\'s ``"default"`` -> '
219
+ '``"Opus 4.7 with 1M context · ..."``).'
220
+ ),
221
+ )
222
+ supports_runtime_model_switch: bool = Field(
223
+ default=False,
224
+ description=(
225
+ "Whether a live, mid-conversation model switch (via "
226
+ "``session/set_model``) will be attempted for this conversation — "
227
+ "tells the inline picker whether to offer a live-switch control. "
228
+ "Mirrors the SDK's switch gate: ``True`` for known switch-capable "
229
+ "providers; ``True`` for unknown/custom ACP servers too, since "
230
+ "OpenHands attempts the switch optimistically rather than refusing "
231
+ "(a rejection then surfaces as an error). ``False`` for native "
232
+ "OpenHands agents, for a known provider that declares no support, "
233
+ "and before the conversation has started a session."
234
+ ),
235
+ )
187
236
 
188
237
 
189
238
  class ConversationInfo(_ConversationInfoBase):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openhands-agent-server
3
- Version: 1.23.0
3
+ Version: 1.24.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "openhands-agent-server"
3
- version = "1.23.0"
3
+ version = "1.24.0"
4
4
  description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
5
5
 
6
6
  requires-python = ">=3.12"
@@ -1,40 +0,0 @@
1
- import os
2
- from urllib.parse import urlparse
3
-
4
- from fastapi.middleware.cors import CORSMiddleware
5
- from starlette.types import ASGIApp
6
-
7
-
8
- class LocalhostCORSMiddleware(CORSMiddleware):
9
- """Custom CORS middleware that allows any request from localhost/127.0.0.1 domains.
10
-
11
- Also allows the DOCKER_HOST_ADDR IP, while using standard CORS rules for
12
- other origins.
13
- """
14
-
15
- def __init__(self, app: ASGIApp, allow_origins: list[str]) -> None:
16
- super().__init__(
17
- app,
18
- allow_origins=allow_origins,
19
- allow_credentials=True,
20
- allow_methods=["*"],
21
- allow_headers=["*"],
22
- )
23
-
24
- def is_allowed_origin(self, origin: str) -> bool:
25
- if origin and not self.allow_origins and not self.allow_origin_regex:
26
- parsed = urlparse(origin)
27
- hostname = parsed.hostname or ""
28
-
29
- # Allow any localhost/127.0.0.1 origin regardless of port
30
- if hostname in ["localhost", "127.0.0.1"]:
31
- return True
32
-
33
- # Also allow DOCKER_HOST_ADDR if set (for remote browser access)
34
- docker_host_addr = os.environ.get("DOCKER_HOST_ADDR")
35
- if docker_host_addr and hostname == docker_host_addr:
36
- return True
37
-
38
- # For missing origin or other origins, use the parent class's logic
39
- result: bool = super().is_allowed_origin(origin)
40
- return result