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.
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/PKG-INFO +1 -1
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/bash_service.py +5 -3
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/conversation_router.py +46 -0
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/conversation_service.py +56 -0
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/docker/Dockerfile +1 -1
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/event_service.py +239 -47
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/models.py +49 -0
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/persistence/models.py +85 -13
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/profiles_router.py +1 -76
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/settings_router.py +16 -1
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands_agent_server.egg-info/PKG-INFO +1 -1
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/pyproject.toml +1 -1
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/__init__.py +0 -0
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/__main__.py +0 -0
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/_secrets_exposure.py +0 -0
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/api.py +0 -0
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/auth_router.py +0 -0
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/bash_router.py +0 -0
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/cloud_proxy_router.py +0 -0
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/config.py +0 -0
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/conversation_lease.py +0 -0
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/conversation_router_acp.py +0 -0
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/dependencies.py +0 -0
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/desktop_router.py +0 -0
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/desktop_service.py +0 -0
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/docker/build.py +0 -0
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/docker/wallpaper.svg +0 -0
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/env_parser.py +0 -0
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/event_router.py +0 -0
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/file_router.py +0 -0
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/git_router.py +0 -0
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/hooks_router.py +0 -0
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/hooks_service.py +0 -0
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/llm_router.py +0 -0
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/logging_config.py +0 -0
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/mcp_router.py +0 -0
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/middleware.py +0 -0
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/openapi.py +0 -0
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/persistence/__init__.py +0 -0
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/persistence/store.py +0 -0
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/pub_sub.py +0 -0
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/py.typed +0 -0
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/server_details_router.py +0 -0
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/skills_router.py +0 -0
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/skills_service.py +0 -0
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/sockets.py +0 -0
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/tool_preload_service.py +0 -0
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/tool_router.py +0 -0
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/utils.py +0 -0
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/vscode_extensions/openhands-settings/extension.js +0 -0
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/vscode_extensions/openhands-settings/package.json +0 -0
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/vscode_router.py +0 -0
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/vscode_service.py +0 -0
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/workspace_router.py +0 -0
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/workspaces_router.py +0 -0
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands_agent_server.egg-info/SOURCES.txt +0 -0
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands_agent_server.egg-info/dependency_links.txt +0 -0
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands_agent_server.egg-info/entry_points.txt +0 -0
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands_agent_server.egg-info/requires.txt +0 -0
- {openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands_agent_server.egg-info/top_level.txt +0 -0
- {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.
|
|
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
|
-
|
|
45
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
628
|
-
#
|
|
629
|
-
#
|
|
630
|
-
|
|
631
|
-
|
|
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
|
|
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
|
-
|
|
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)
|
|
744
|
-
|
|
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
|
-
#
|
|
889
|
+
# All guards are required:
|
|
777
890
|
# • iscoroutinefunction – filters out non-async objects
|
|
778
891
|
# (e.g. MagicMock in tests).
|
|
779
|
-
# • override
|
|
780
|
-
# ``
|
|
781
|
-
#
|
|
782
|
-
#
|
|
783
|
-
#
|
|
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
|
|
816
|
-
#
|
|
817
|
-
#
|
|
818
|
-
#
|
|
819
|
-
#
|
|
820
|
-
#
|
|
821
|
-
#
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
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):
|
{openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/models.py
RENAMED
|
@@ -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(
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
50
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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.
|
|
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.
|
|
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
|
{openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/__init__.py
RENAMED
|
File without changes
|
{openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/__main__.py
RENAMED
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/api.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/config.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/env_parser.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/git_router.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/llm_router.py
RENAMED
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/mcp_router.py
RENAMED
|
File without changes
|
{openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/middleware.py
RENAMED
|
File without changes
|
{openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/openapi.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/pub_sub.py
RENAMED
|
File without changes
|
{openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/py.typed
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/sockets.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.23.1 → openhands_agent_server-1.25.0}/openhands/agent_server/utils.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|