openhands-agent-server 1.23.1__tar.gz → 1.25.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.23.1 → openhands_agent_server-1.25.0}/PKG-INFO +1 -1
  2. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/bash_service.py +5 -3
  3. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/conversation_router.py +46 -0
  4. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/conversation_service.py +56 -0
  5. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/docker/Dockerfile +1 -1
  6. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/event_service.py +239 -47
  7. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/models.py +49 -0
  8. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/persistence/models.py +85 -13
  9. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/profiles_router.py +1 -76
  10. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/settings_router.py +16 -1
  11. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands_agent_server.egg-info/PKG-INFO +1 -1
  12. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/pyproject.toml +1 -1
  13. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/__init__.py +0 -0
  14. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/__main__.py +0 -0
  15. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/_secrets_exposure.py +0 -0
  16. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/api.py +0 -0
  17. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/auth_router.py +0 -0
  18. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/bash_router.py +0 -0
  19. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/cloud_proxy_router.py +0 -0
  20. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/config.py +0 -0
  21. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/conversation_lease.py +0 -0
  22. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/conversation_router_acp.py +0 -0
  23. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/dependencies.py +0 -0
  24. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/desktop_router.py +0 -0
  25. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/desktop_service.py +0 -0
  26. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/docker/build.py +0 -0
  27. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/docker/wallpaper.svg +0 -0
  28. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/env_parser.py +0 -0
  29. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/event_router.py +0 -0
  30. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/file_router.py +0 -0
  31. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/git_router.py +0 -0
  32. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/hooks_router.py +0 -0
  33. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/hooks_service.py +0 -0
  34. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/llm_router.py +0 -0
  35. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/logging_config.py +0 -0
  36. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/mcp_router.py +0 -0
  37. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/middleware.py +0 -0
  38. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/openapi.py +0 -0
  39. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/persistence/__init__.py +0 -0
  40. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/persistence/store.py +0 -0
  41. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/pub_sub.py +0 -0
  42. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/py.typed +0 -0
  43. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/server_details_router.py +0 -0
  44. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/skills_router.py +0 -0
  45. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/skills_service.py +0 -0
  46. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/sockets.py +0 -0
  47. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/tool_preload_service.py +0 -0
  48. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/tool_router.py +0 -0
  49. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/utils.py +0 -0
  50. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/vscode_extensions/openhands-settings/extension.js +0 -0
  51. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/vscode_extensions/openhands-settings/package.json +0 -0
  52. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/vscode_router.py +0 -0
  53. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/vscode_service.py +0 -0
  54. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/workspace_router.py +0 -0
  55. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/workspaces_router.py +0 -0
  56. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands_agent_server.egg-info/SOURCES.txt +0 -0
  57. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands_agent_server.egg-info/dependency_links.txt +0 -0
  58. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands_agent_server.egg-info/entry_points.txt +0 -0
  59. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands_agent_server.egg-info/requires.txt +0 -0
  60. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands_agent_server.egg-info/top_level.txt +0 -0
  61. {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.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.1
3
+ Version: 1.25.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
@@ -41,11 +41,13 @@ class BashEventService:
41
41
  self.bash_events_dir.mkdir(parents=True, exist_ok=True)
42
42
 
43
43
  def _timestamp_to_str(self, timestamp: datetime) -> str:
44
- result = timestamp.strftime("%Y%m%d%H%M%S")
45
- return result
44
+ # Include microseconds so filename-based ordering reflects emission
45
+ # order for sub-second bursts (e.g. fast `yes`-style floods that
46
+ # emit several BashOutput chunks in the same wall-clock second).
47
+ return timestamp.strftime("%Y%m%d%H%M%S%f")
46
48
 
47
49
  def _get_event_filename(self, event: BashEventBase) -> str:
48
- """Generate filename using YYYYMMDDHHMMSS_eventId_actionId format."""
50
+ """Generate filename using YYYYMMDDHHMMSSffffff_eventId_actionId format."""
49
51
  result = [self._timestamp_to_str(event.timestamp), event.kind]
50
52
  command_id = getattr(event, "command_id", None)
51
53
  if command_id:
@@ -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
 
@@ -172,7 +172,7 @@ RUN set -ux; \
172
172
  PATH="$ACP_NODE_DIR/bin:$PATH"; \
173
173
  if "$ACP_NODE_DIR/bin/npm" install -g \
174
174
  @agentclientprotocol/claude-agent-acp@0.30.0 \
175
- @zed-industries/codex-acp@0.11.1 \
175
+ @zed-industries/codex-acp@0.15.0 \
176
176
  @google/gemini-cli@0.38.0; then \
177
177
  # Create wrappers in /usr/local/bin that prepend ACP's Node 22 to PATH.
178
178
  # This ensures the ACP binary's #!/usr/bin/env node shebang resolves
@@ -18,8 +18,13 @@ 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.agent import ACPAgent
21
22
  from openhands.sdk.conversation.base import BaseConversation
22
- from openhands.sdk.conversation.impl.local_conversation import LocalConversation
23
+ from openhands.sdk.conversation.impl.local_conversation import (
24
+ ACP_INFLIGHT_PROMPT_USER_MESSAGE_ID,
25
+ ACP_SUPERSEDE_INFLIGHT_PROMPT,
26
+ LocalConversation,
27
+ )
23
28
  from openhands.sdk.conversation.response_utils import get_agent_final_response
24
29
  from openhands.sdk.conversation.secret_registry import SecretValue
25
30
  from openhands.sdk.conversation.state import (
@@ -71,6 +76,15 @@ class EventService:
71
76
  # Set when a send_message(run=True) is rejected because a run is still
72
77
  # wrapping up; consumed by _run_and_publish to re-run the stranded message.
73
78
  _rerun_requested: bool = field(default=False, init=False)
79
+ # Set only for the internal ACP interrupt/restart path triggered by a new
80
+ # send_message(run=True). Explicit user pause/interrupt clears it so user
81
+ # stop intent wins over an earlier automatic restart request.
82
+ _acp_internal_rerun_requested: bool = field(default=False, init=False)
83
+ # Incremented for explicit user pause/interrupt requests. Internal ACP
84
+ # supersede restarts compare this generation after their interrupt drains
85
+ # so a later Stop/Pause cannot be overwritten by an automatic restart.
86
+ _explicit_interrupt_generation: int = field(default=0, init=False)
87
+ _closing: bool = field(default=False, init=False)
74
88
  _run_lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False)
75
89
  _callback_wrapper: AsyncCallbackWrapper | None = field(default=None, init=False)
76
90
  _lease: ConversationLease | None = field(default=None, init=False)
@@ -372,6 +386,23 @@ class EventService:
372
386
  loop = asyncio.get_running_loop()
373
387
  return await loop.run_in_executor(None, self._get_execution_status_sync)
374
388
 
389
+ def _mark_error_status_sync(self) -> None:
390
+ """Force the conversation into ERROR status (idempotent backstop).
391
+
392
+ Called when a run task raised before the conversation could set its own
393
+ ERROR status — e.g. an exception in ``init_state``, which executes
394
+ outside ``run()``/``arun()``'s try-block (via ``_ensure_agent_ready()``).
395
+ Without this, the run's finally would publish a stale non-error status
396
+ (IDLE/RUNNING) and the failure would look like a clean stop. No-op once
397
+ the status is already ERROR. Best-effort: never raises (the caller is an
398
+ error handler).
399
+ """
400
+ if not self._conversation:
401
+ return
402
+ with self._conversation._state as state:
403
+ if state.execution_status != ConversationExecutionStatus.ERROR:
404
+ state.execution_status = ConversationExecutionStatus.ERROR
405
+
375
406
  def _create_state_update_event_sync(self) -> ConversationStateUpdateEvent:
376
407
  if not self._conversation:
377
408
  raise ValueError("inactive_service")
@@ -419,11 +450,28 @@ class EventService:
419
450
  async def send_message(self, message: Message, run: bool = False):
420
451
  if not self._conversation:
421
452
  raise ValueError("inactive_service")
453
+ explicit_interrupt_generation = self._explicit_interrupt_generation
422
454
  loop = asyncio.get_running_loop()
423
455
  await loop.run_in_executor(None, self._conversation.send_message, message)
424
456
  if run:
457
+ if self._explicit_interrupt_generation != explicit_interrupt_generation:
458
+ return
459
+ (
460
+ did_mark_acp_prompt_superseded,
461
+ active_acp_prompt_has_latest_message,
462
+ ) = await self._mark_running_acp_prompt_superseded()
463
+ interrupted_acp = False
464
+ if did_mark_acp_prompt_superseded:
465
+ self._acp_internal_rerun_requested = True
466
+ interrupted_acp = True
467
+ await self.interrupt(internal_acp_rerun=True)
468
+ if self._explicit_interrupt_generation != explicit_interrupt_generation:
469
+ return
425
470
  try:
426
- await self.run()
471
+ await self.run(
472
+ acp_internal_rerun_generation=explicit_interrupt_generation
473
+ )
474
+ self._acp_internal_rerun_requested = False
427
475
  except ValueError as e:
428
476
  # run() refused. If a run is still wrapping up (its
429
477
  # wait_for_pending tail), the message we just appended won't be
@@ -433,8 +481,53 @@ class EventService:
433
481
  # is what keeps a deliberate run=False append, or an IDLE reached
434
482
  # via another path, from triggering an unwanted run.
435
483
  # "inactive_service" is terminal and must not re-arm.
436
- if str(e) == "conversation_already_running":
484
+ if (
485
+ str(e) == "conversation_already_running"
486
+ and not active_acp_prompt_has_latest_message
487
+ ):
437
488
  self._rerun_requested = True
489
+ if interrupted_acp:
490
+ self._acp_internal_rerun_requested = True
491
+
492
+ def _mark_running_acp_prompt_superseded_sync(self) -> tuple[bool, bool]:
493
+ """Mark the currently running ACP prompt superseded if needed.
494
+
495
+ The tuple is ``(did_mark_superseded, active_prompt_has_latest_message)``.
496
+ If the running ACP prompt has already advanced to the newly appended
497
+ user message, interrupting it would cancel the replacement prompt and
498
+ strand that message behind the persisted cursor.
499
+ """
500
+ if not self._conversation:
501
+ return (False, False)
502
+ if self._run_task is None or self._run_task.done():
503
+ return (False, False)
504
+ if not isinstance(self._conversation.agent, ACPAgent):
505
+ return (False, False)
506
+ with self._conversation._state as state:
507
+ if state.execution_status != ConversationExecutionStatus.RUNNING:
508
+ return (False, False)
509
+ inflight_prompt_user_message_id = state.agent_state.get(
510
+ ACP_INFLIGHT_PROMPT_USER_MESSAGE_ID
511
+ )
512
+ last_user_message_id = state.last_user_message_id
513
+ if inflight_prompt_user_message_id is None or last_user_message_id is None:
514
+ return (False, False)
515
+ active_prompt_has_latest_message = (
516
+ inflight_prompt_user_message_id == last_user_message_id
517
+ )
518
+ if active_prompt_has_latest_message:
519
+ return (False, True)
520
+ state.agent_state = {
521
+ **state.agent_state,
522
+ ACP_SUPERSEDE_INFLIGHT_PROMPT: True,
523
+ }
524
+ return (True, False)
525
+
526
+ async def _mark_running_acp_prompt_superseded(self) -> tuple[bool, bool]:
527
+ loop = asyncio.get_running_loop()
528
+ return await loop.run_in_executor(
529
+ None, self._mark_running_acp_prompt_superseded_sync
530
+ )
438
531
 
439
532
  async def subscribe_to_events(self, subscriber: Subscriber[Event]) -> UUID:
440
533
  subscriber_id = self._pub_sub.subscribe(subscriber)
@@ -624,41 +717,53 @@ class EventService:
624
717
  self._pub_sub, loop=asyncio.get_running_loop()
625
718
  )
626
719
 
627
- # Only wire token streaming if at least one LLM has stream=True.
628
- # The LLM silently ignores on_token when stream is off, but skipping
629
- # the wiring lets us log the decision so operators can tell from a
630
- # log line whether deltas will flow.
631
- streaming_enabled = any(llm.stream for llm in agent.get_all_llms())
720
+ # Only wire token streaming for agents that can actually emit token
721
+ # callbacks. SDK LLM agents need stream=True, while ACP agents emit
722
+ # AgentMessageChunk text through their bridge without exposing an LLM.
723
+ streaming_enabled = isinstance(agent, ACPAgent) or any(
724
+ llm.stream for llm in agent.get_all_llms()
725
+ )
632
726
  logger.debug(
633
727
  "Token streaming: %s",
634
728
  "enabled" if streaming_enabled else "disabled (no LLM has stream=True)",
635
729
  )
636
730
 
637
- def _token_streaming_callback(chunk: LLMStreamChunk) -> None:
731
+ def _publish_stream_delta(
732
+ content: str | None = None,
733
+ reasoning_content: str | None = None,
734
+ ) -> None:
638
735
  # Published directly to _pub_sub (not via _callback_wrapper) so
639
736
  # deltas reach subscribers but are NOT persisted to
640
737
  # ConversationState.events. See StreamingDeltaEvent docstring.
641
738
  if not self._main_loop or not self._main_loop.is_running():
642
739
  return
740
+ # Use `is not None` rather than truthiness: some providers
741
+ # emit legitimate empty-string chunks at stream boundaries
742
+ # (e.g. after a tool call) that we still want to forward.
743
+ if content is None and reasoning_content is None:
744
+ return
745
+ event = StreamingDeltaEvent(
746
+ content=content,
747
+ reasoning_content=reasoning_content,
748
+ )
749
+ with suppress(RuntimeError): # main loop already closed during teardown
750
+ asyncio.run_coroutine_threadsafe(self._pub_sub(event), self._main_loop)
751
+
752
+ def _token_streaming_callback(chunk: LLMStreamChunk | str) -> None:
753
+ if isinstance(chunk, str):
754
+ _publish_stream_delta(content=chunk)
755
+ return
756
+
643
757
  for choice in chunk.choices or ():
644
758
  delta = choice.delta
645
759
  if delta is None:
646
760
  continue
647
761
  content = getattr(delta, "content", None)
648
762
  reasoning = getattr(delta, "reasoning_content", None)
649
- # Use `is not None` rather than truthiness: some providers
650
- # emit legitimate empty-string chunks at stream boundaries
651
- # (e.g. after a tool call) that we still want to forward.
652
- if content is None and reasoning is None:
653
- continue
654
- event = StreamingDeltaEvent(
763
+ _publish_stream_delta(
655
764
  content=content if isinstance(content, str) else None,
656
765
  reasoning_content=reasoning if isinstance(reasoning, str) else None,
657
766
  )
658
- with suppress(RuntimeError):
659
- asyncio.run_coroutine_threadsafe(
660
- self._pub_sub(event), self._main_loop
661
- )
662
767
 
663
768
  conversation = LocalConversation(
664
769
  agent=agent,
@@ -733,20 +838,21 @@ class EventService:
733
838
  # Publish initial state update
734
839
  await self._publish_state_update()
735
840
 
736
- async def run(self):
841
+ async def run(self, acp_internal_rerun_generation: int | None = None):
737
842
  """Run the conversation asynchronously in the background.
738
843
 
739
844
  This method starts the conversation run in a background task and returns
740
845
  immediately. When possible, the conversation is driven via its native
741
846
  ``arun()`` coroutine so LLM I/O does not tie up a thread-pool worker.
742
847
  For conversations that do not expose ``arun()`` (e.g., custom
743
- subclasses), the synchronous ``run()`` is executed in the thread pool as
744
- before.
848
+ subclasses) or whose agent only implements sync ``step()`` (no
849
+ ``astep()`` override), the synchronous ``run()`` is executed
850
+ in the thread pool as before.
745
851
 
746
852
  Raises:
747
853
  ValueError: If the service is inactive or conversation is already running.
748
854
  """
749
- if not self._conversation:
855
+ if not self._conversation or self._closing:
750
856
  raise ValueError("inactive_service")
751
857
 
752
858
  # Use lock to make check-and-set atomic, preventing race conditions
@@ -756,6 +862,13 @@ class EventService:
756
862
  == ConversationExecutionStatus.RUNNING
757
863
  ):
758
864
  raise ValueError("conversation_already_running")
865
+ if self._closing:
866
+ raise ValueError("inactive_service")
867
+ if (
868
+ acp_internal_rerun_generation is not None
869
+ and self._explicit_interrupt_generation != acp_internal_rerun_generation
870
+ ):
871
+ return
759
872
 
760
873
  # Check if there's already a running task
761
874
  if self._run_task is not None and not self._run_task.done():
@@ -773,19 +886,23 @@ class EventService:
773
886
  # loop is free during LLM I/O. Fall back to thread-pool
774
887
  # execution for backward compatibility.
775
888
  #
776
- # Both guards are required:
889
+ # All guards are required:
777
890
  # • iscoroutinefunction – filters out non-async objects
778
891
  # (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.
892
+ # • conversation override – BaseConversation's default
893
+ # ``arun()`` delegates to sync ``run()``, so we require an
894
+ # *actual* override to avoid running a sync-only subclass
895
+ # on the event loop.
896
+ # agent override ``LocalConversation`` always overrides
897
+ # ``arun()``, but an agent without an ``astep()`` override
898
+ # runs sync ``step()`` in a worker thread; route it
899
+ # through sync ``run()`` instead.
784
900
  arun = getattr(conversation, "arun", None)
785
901
  has_native_arun = (
786
902
  arun is not None
787
903
  and asyncio.iscoroutinefunction(arun)
788
904
  and type(conversation).arun is not BaseConversation.arun
905
+ and type(conversation.agent).astep is not AgentBase.astep
789
906
  )
790
907
  if has_native_arun:
791
908
  await conversation.arun()
@@ -793,6 +910,13 @@ class EventService:
793
910
  await loop.run_in_executor(self._run_executor, conversation.run)
794
911
  except Exception:
795
912
  logger.exception("Error during conversation run")
913
+ # Backstop: a run that raised before reaching its own error
914
+ # handling (e.g. an ACP cold-start failure in init_state,
915
+ # which runs outside run()/arun()'s try-block) can leave the
916
+ # status at IDLE/RUNNING. Force ERROR so the finally's
917
+ # _publish_state_update() surfaces the failure instead of a
918
+ # misleading non-error state.
919
+ await loop.run_in_executor(None, self._mark_error_status_sync)
796
920
  finally:
797
921
  # Wait for all pending events to be published via
798
922
  # AsyncCallbackWrapper before publishing the final state update.
@@ -812,21 +936,53 @@ class EventService:
812
936
  # wrapping up. A send_message(run=True) that arrived during
813
937
  # the wait_for_pending() tail above had its run() rejected as
814
938
  # "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()
939
+ # _rerun_requested. Honor it while the conversation is IDLE
940
+ # (pending input) or internally ACP-interrupted PAUSED (the
941
+ # old task finished its interrupt before the replacement run
942
+ # could start). Explicit user pause/interrupt clears the
943
+ # internal ACP flag, so user stop intent wins over an older
944
+ # automatic restart request. If the run loop was still alive
945
+ # it already absorbed the message and we are FINISHED here,
946
+ # so the guard avoids a redundant run. A deliberate
947
+ # run=False append, or an IDLE reached via another path,
948
+ # never sets the flag.
949
+ rerun_requested = self._rerun_requested
950
+ acp_internal_rerun_requested = self._acp_internal_rerun_requested
951
+ rerun_generation = self._explicit_interrupt_generation
952
+ self._rerun_requested = False
953
+ self._acp_internal_rerun_requested = False
954
+ if rerun_requested:
955
+ status = await self._get_execution_status()
956
+ rerun_generation_still_valid = (
957
+ self._explicit_interrupt_generation == rerun_generation
958
+ )
959
+ acp_internal_rerun_still_valid = (
960
+ acp_internal_rerun_requested
961
+ and rerun_generation_still_valid
962
+ )
963
+ should_restart = rerun_generation_still_valid and (
964
+ status == ConversationExecutionStatus.IDLE
965
+ or (
966
+ acp_internal_rerun_still_valid
967
+ and status == ConversationExecutionStatus.PAUSED
968
+ and isinstance(conversation.agent, ACPAgent)
969
+ )
970
+ )
971
+ if should_restart:
972
+ try:
973
+ await self.run(
974
+ acp_internal_rerun_generation=rerun_generation
975
+ if acp_internal_rerun_still_valid
976
+ else None
977
+ )
978
+ except ValueError as e:
979
+ if str(e) == "conversation_already_running":
980
+ self._rerun_requested = True
981
+ self._acp_internal_rerun_requested = (
982
+ acp_internal_rerun_requested
983
+ )
984
+ else:
985
+ raise
830
986
 
831
987
  # Create task but don't await it - runs in background
832
988
  self._run_task = asyncio.create_task(_run_and_publish())
@@ -857,12 +1013,15 @@ class EventService:
857
1013
 
858
1014
  async def pause(self):
859
1015
  if self._conversation:
1016
+ self._explicit_interrupt_generation += 1
1017
+ self._rerun_requested = False
1018
+ self._acp_internal_rerun_requested = False
860
1019
  loop = asyncio.get_running_loop()
861
1020
  await loop.run_in_executor(None, self._conversation.pause)
862
1021
  # Publish state update after pause to ensure stats are updated
863
1022
  await self._publish_state_update()
864
1023
 
865
- async def interrupt(self):
1024
+ async def interrupt(self, *, internal_acp_rerun: bool = False):
866
1025
  """Immediately cancel an in-flight async LLM call.
867
1026
 
868
1027
  Delegates to :meth:`LocalConversation.interrupt` which cancels the
@@ -870,12 +1029,18 @@ class EventService:
870
1029
  back to :meth:`pause`.
871
1030
  """
872
1031
  if self._conversation:
1032
+ if not internal_acp_rerun:
1033
+ self._explicit_interrupt_generation += 1
1034
+ self._rerun_requested = False
1035
+ self._acp_internal_rerun_requested = False
873
1036
  self._conversation.interrupt()
874
1037
  # Wait for the run task to finish so we can publish the final
875
- # state update (PAUSED + InterruptEvent) cleanly.
1038
+ # state update (PAUSED + InterruptEvent) cleanly. The shield keeps
1039
+ # the 5s timeout from force-cancelling a cleanup that still needs
1040
+ # to drain its ACP prompt/cancel handshake.
876
1041
  if self._run_task is not None and not self._run_task.done():
877
1042
  with suppress(Exception):
878
- await asyncio.wait_for(self._run_task, timeout=5.0)
1043
+ await asyncio.wait_for(asyncio.shield(self._run_task), timeout=5.0)
879
1044
  # Only clear _run_task if it actually finished; if
880
1045
  # wait_for timed out the task may still be running and
881
1046
  # clearing prematurely would allow a second run() to
@@ -911,7 +1076,34 @@ class EventService:
911
1076
  None, self._conversation.set_security_analyzer, security_analyzer
912
1077
  )
913
1078
 
1079
+ async def switch_acp_model(self, model: str) -> None:
1080
+ """Switch the model on a running ACP conversation, mid-conversation.
1081
+
1082
+ Runs the (blocking) protocol-level ``session/set_model`` round-trip in a
1083
+ worker thread, then mirrors the new model into ``meta.json`` so the
1084
+ switch survives an agent-server restart: ``start()`` rebuilds the agent
1085
+ from ``self.stored.agent`` and ``ConversationState.create()`` copies
1086
+ that over the persisted base_state.json on resume. Only ``acp_model``
1087
+ needs updating — ``model_post_init`` re-derives the sentinel
1088
+ ``llm.model`` on reload.
1089
+ """
1090
+ if self._conversation is None:
1091
+ raise RuntimeError(
1092
+ "Conversation is not active; it has not been started or has "
1093
+ "been closed."
1094
+ )
1095
+ loop = asyncio.get_running_loop()
1096
+ await loop.run_in_executor(None, self._conversation.switch_acp_model, model)
1097
+ self.stored = self.stored.model_copy(
1098
+ update={"agent": self.stored.agent.model_copy(update={"acp_model": model})}
1099
+ )
1100
+ await self.save_meta()
1101
+
914
1102
  async def close(self):
1103
+ self._closing = True
1104
+ self._explicit_interrupt_generation += 1
1105
+ self._rerun_requested = False
1106
+ self._acp_internal_rerun_requested = False
915
1107
  if self._lease_task is not None:
916
1108
  self._lease_task.cancel()
917
1109
  with suppress(asyncio.CancelledError):
@@ -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):
@@ -32,23 +32,67 @@ from openhands.sdk.utils.pydantic_secrets import serialize_secret, validate_secr
32
32
 
33
33
 
34
34
  class SettingsUpdatePayload(TypedDict, total=False):
35
- """Typed payload for PersistedSettings.update() method."""
35
+ """Typed payload for PersistedSettings.update() method.
36
+
37
+ The ``*_diff`` dicts are deep-merged via :func:`_deep_merge`: nested
38
+ objects merge recursively, and a ``None`` value *inside a nested map*
39
+ deletes that entry (the "unset" primitive) — e.g. send
40
+ ``{"acp_env": {"NAME": None}}`` to drop one env-var without re-sending the
41
+ whole map. A ``None`` on a top-level *field* is not treated as delete; it
42
+ flows to validation as before.
43
+ """
36
44
 
37
45
  agent_settings_diff: dict[str, Any]
38
46
  conversation_settings_diff: dict[str, Any]
39
47
  active_profile: str | None
40
48
 
41
49
 
42
- def _deep_merge(base: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]:
43
- """Recursively merge overlay dict into base dict.
44
-
45
- For nested dicts, merges recursively. For other types, overlay wins.
50
+ def _deep_merge(
51
+ base: dict[str, Any],
52
+ overlay: dict[str, Any],
53
+ *,
54
+ unset_nulls: bool = False,
55
+ ) -> dict[str, Any]:
56
+ """Recursively merge ``overlay`` into ``base``.
57
+
58
+ - Nested dicts are merged recursively.
59
+ - **Inside a nested map** a ``None`` value **removes** that key — the
60
+ "unset" primitive a plain deep-merge lacks. It lets a
61
+ ``PATCH /api/settings`` diff delete a single map entry (one
62
+ ``acp_env`` / MCP ``env`` key) without round-tripping the whole map::
63
+
64
+ {"agent_settings_diff": {"acp_env": {"STALE_KEY": null}}}
65
+
66
+ - **At the top level** (a settings *field* like ``confirmation_mode`` or
67
+ ``acp_env`` itself) a ``None`` is left as-is and flows to model
68
+ validation — exactly as before this primitive existed. So a stray
69
+ ``{"confirmation_mode": null}`` still fails loudly (422) instead of
70
+ silently resetting a field to its default. This scoping is deliberate:
71
+ ``unset`` is for *entries within* a map, not for nulling whole fields.
72
+ - For any other scalar/list value, the overlay wins.
73
+
74
+ ``unset_nulls`` is ``False`` for the top-level call and ``True`` for every
75
+ recursive (nested) call — that's what draws the field-vs-entry line above.
76
+
77
+ Corner case: a key **absent from** ``base`` whose overlay value is a dict
78
+ is assigned wholesale (no recursion), so any ``null`` entries inside that
79
+ dict are stored as-is rather than treated as deletes. This is intentional
80
+ — you can't delete an entry from a map that doesn't exist yet — but it
81
+ means "initialize a new map and unset a key within it" in one diff won't
82
+ strip the null; downstream validation handles the resulting value.
46
83
  """
47
84
  result = dict(base)
48
85
  for key, value in overlay.items():
49
- if key in result and isinstance(result[key], dict) and isinstance(value, dict):
50
- result[key] = _deep_merge(result[key], value)
86
+ if value is None and unset_nulls:
87
+ # Nested map entry: a null member removes the key (no-op if absent).
88
+ result.pop(key, None)
89
+ elif (
90
+ key in result and isinstance(result[key], dict) and isinstance(value, dict)
91
+ ):
92
+ result[key] = _deep_merge(result[key], value, unset_nulls=True)
51
93
  else:
94
+ # Top-level null (unset_nulls=False) falls here: set as-is and let
95
+ # model validation decide (preserves pre-existing behavior).
52
96
  result[key] = value
53
97
  return result
54
98
 
@@ -102,6 +146,11 @@ class PersistedSettings(BaseModel):
102
146
  apply any schema migrations if the incoming diff contains an older
103
147
  schema version.
104
148
 
149
+ When ``agent_kind`` changes in the diff, the update is treated as a
150
+ variant replacement: the incoming diff is validated as-is rather than
151
+ merged with the old variant's fields. Same-kind updates retain deep-merge
152
+ behavior for incremental field edits.
153
+
105
154
  Thread Safety:
106
155
  This method is NOT thread-safe for concurrent in-memory updates.
107
156
  The assignments to ``agent_settings`` and ``conversation_settings``
@@ -132,12 +181,35 @@ class PersistedSettings(BaseModel):
132
181
 
133
182
  try:
134
183
  if isinstance(agent_update, dict):
135
- agent_merged = _deep_merge(
136
- self.agent_settings.model_dump(
137
- mode="json", context={"expose_secrets": "plaintext"}
138
- ),
139
- agent_update,
140
- )
184
+ # Check if this is a variant (agent_kind) switch
185
+ old_kind = self.agent_settings.agent_kind
186
+ new_kind = agent_update.get("agent_kind")
187
+ is_kind_switch = new_kind is not None and new_kind != old_kind
188
+
189
+ if is_kind_switch:
190
+ # Variant replacement: validate the diff as-is rather than
191
+ # deep-merging it onto the old variant. A kind switch picks a
192
+ # different member of the AgentSettingsConfig union, and the
193
+ # old variant's serialized fields are not a valid base for the
194
+ # new one (e.g. ACP's acp_command has no place in
195
+ # OpenHandsAgentSettings and would fail validation).
196
+ #
197
+ # Consequence (intentional): fields the two variants happen to
198
+ # share (e.g. ``llm``) are NOT carried over — they fall back to
199
+ # the new variant's defaults unless the caller restates them in
200
+ # this same diff. Switching kinds is a fresh start on the new
201
+ # variant, mirroring the frontend's "fresh base on kind switch"
202
+ # behaviour. Callers that want to preserve a shared field must
203
+ # include it in the switch payload.
204
+ agent_merged = agent_update
205
+ else:
206
+ # Same-kind update: deep-merge for incremental field edits
207
+ agent_merged = _deep_merge(
208
+ self.agent_settings.model_dump(
209
+ mode="json", context={"expose_secrets": "plaintext"}
210
+ ),
211
+ agent_update,
212
+ )
141
213
  try:
142
214
  new_agent = validate_agent_settings(agent_merged)
143
215
  except Exception as e:
@@ -105,38 +105,6 @@ def _has_api_key(llm: LLM) -> bool:
105
105
  return bool(llm.api_key.get_secret_value().strip())
106
106
 
107
107
 
108
- def _model_to_profile_name(model: str) -> str:
109
- """Convert a model name to a valid profile name.
110
-
111
- Transforms model names like "openai/gpt-4o" or "anthropic/claude-3-opus"
112
- into valid profile names by:
113
- - Taking just the model part after provider prefix (if present)
114
- - Replacing invalid characters with dashes
115
- - Truncating to max 64 characters
116
- """
117
- import re
118
-
119
- # Extract model name after provider prefix (e.g., "openai/gpt-4o" -> "gpt-4o")
120
- if "/" in model:
121
- model = model.rsplit("/", 1)[-1]
122
-
123
- # Replace any character that's not alphanumeric, dash, underscore, or dot
124
- # Profile names must match: ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$
125
- sanitized = re.sub(r"[^A-Za-z0-9._-]", "-", model)
126
-
127
- # Ensure it starts with alphanumeric (required by profile name pattern)
128
- if sanitized and not sanitized[0].isalnum():
129
- sanitized = "m" + sanitized
130
-
131
- # Truncate to max 64 characters
132
- sanitized = sanitized[:64]
133
-
134
- # Remove trailing non-alphanumeric characters
135
- sanitized = sanitized.rstrip("._-")
136
-
137
- return sanitized or "default"
138
-
139
-
140
108
  @profiles_router.get("", response_model=ProfileListResponse)
141
109
  async def list_profiles(request: Request) -> ProfileListResponse:
142
110
  """List all saved LLM profiles.
@@ -144,17 +112,7 @@ async def list_profiles(request: Request) -> ProfileListResponse:
144
112
  Returns the list of profiles along with the currently active profile name,
145
113
  if one has been activated. The active_profile tracks which LLM profile
146
114
  configuration is currently in use.
147
-
148
- Auto-creates a profile named after the model if:
149
- - No profiles exist
150
- - agent_settings.llm has an API key configured
151
-
152
- The API key check ensures we only auto-create when the user has actually
153
- configured their LLM (not just relying on defaults). This allows users
154
- with existing LLM configurations to see their settings as a profile
155
- without manual creation.
156
115
  """
157
- cipher = get_cipher(request)
158
116
  config = get_config(request)
159
117
  settings_store = get_settings_store(config)
160
118
  settings = settings_store.load() or PersistedSettings()
@@ -163,42 +121,9 @@ async def list_profiles(request: Request) -> ProfileListResponse:
163
121
  with _store_errors():
164
122
  summaries = store.list_summaries()
165
123
 
166
- active_profile = settings.active_profile
167
-
168
- # Auto-create profile from existing LLM settings if no profiles exist
169
- # but an API key is configured. Use the model name as the profile name.
170
- if not summaries and settings.llm_api_key_is_set:
171
- llm = settings.agent_settings.llm
172
- profile_name = _model_to_profile_name(llm.model or "default")
173
- try:
174
- with _store_errors():
175
- store.save(
176
- profile_name,
177
- llm,
178
- include_secrets=True,
179
- cipher=cipher,
180
- )
181
-
182
- # Update settings to mark this as active
183
- def set_active(s: PersistedSettings) -> PersistedSettings:
184
- s.active_profile = profile_name
185
- return s
186
-
187
- settings_store.update(set_active)
188
- active_profile = profile_name
189
-
190
- # Refresh summaries to include the new profile
191
- summaries = store.list_summaries()
192
- logger.info(
193
- f"Auto-created '{profile_name}' profile from existing LLM settings"
194
- )
195
- except Exception as e:
196
- # Log but don't fail - auto-creation is a convenience feature
197
- logger.warning(f"Failed to auto-create profile: {e}")
198
-
199
124
  return ProfileListResponse(
200
125
  profiles=[ProfileInfo(**s) for s in summaries],
201
- active_profile=active_profile,
126
+ active_profile=settings.active_profile,
202
127
  )
203
128
 
204
129
 
@@ -170,7 +170,22 @@ async def update_settings(
170
170
  """Update settings with partial changes.
171
171
 
172
172
  Accepts ``agent_settings_diff`` and/or ``conversation_settings_diff``
173
- for incremental updates. Values are deep-merged with existing settings.
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::
177
+
178
+ PATCH /api/settings
179
+ {"agent_settings_diff": {"acp_env": {"STALE_KEY": null}}}
180
+
181
+ or to remove one MCP server's header::
182
+
183
+ {"agent_settings_diff":
184
+ {"mcp_config": {"mcpServers": {"svc": {"headers": {"X-Old": null}}}}}}
185
+
186
+ A ``null`` on a top-level *field* (e.g. ``{"confirmation_mode": null}``)
187
+ is **not** an unset — it flows to model validation as before, so it still
188
+ fails loudly rather than silently resetting the field to its default.
174
189
 
175
190
  Uses file locking to prevent concurrent updates from overwriting each other.
176
191
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openhands-agent-server
3
- Version: 1.23.1
3
+ Version: 1.25.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.1"
3
+ version = "1.25.0"
4
4
  description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
5
5
 
6
6
  requires-python = ">=3.12"