openhands-agent-server 1.22.1__tar.gz → 1.23.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/PKG-INFO +1 -1
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/api.py +27 -3
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/bash_service.py +72 -2
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/config.py +17 -2
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/conversation_router.py +52 -1
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/conversation_router_acp.py +27 -2
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/conversation_service.py +19 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/event_service.py +113 -9
- openhands_agent_server-1.23.1/openhands/agent_server/mcp_router.py +225 -0
- openhands_agent_server-1.23.1/openhands/agent_server/middleware.py +94 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/models.py +54 -3
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/persistence/__init__.py +14 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/persistence/models.py +49 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/persistence/store.py +107 -1
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/server_details_router.py +10 -0
- openhands_agent_server-1.23.1/openhands/agent_server/skills_router.py +530 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/skills_service.py +298 -0
- openhands_agent_server-1.23.1/openhands/agent_server/workspaces_router.py +244 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands_agent_server.egg-info/PKG-INFO +1 -1
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands_agent_server.egg-info/SOURCES.txt +4 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/pyproject.toml +1 -1
- openhands_agent_server-1.22.1/openhands/agent_server/middleware.py +0 -40
- openhands_agent_server-1.22.1/openhands/agent_server/skills_router.py +0 -192
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/__init__.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/__main__.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/_secrets_exposure.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/auth_router.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/bash_router.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/cloud_proxy_router.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/conversation_lease.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/dependencies.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/desktop_router.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/desktop_service.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/docker/Dockerfile +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/docker/build.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/docker/wallpaper.svg +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/env_parser.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/event_router.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/file_router.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/git_router.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/hooks_router.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/hooks_service.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/llm_router.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/logging_config.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/openapi.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/profiles_router.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/pub_sub.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/py.typed +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/settings_router.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/sockets.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/tool_preload_service.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/tool_router.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/utils.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/vscode_extensions/openhands-settings/extension.js +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/vscode_extensions/openhands-settings/package.json +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/vscode_router.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/vscode_service.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/workspace_router.py +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands_agent_server.egg-info/dependency_links.txt +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands_agent_server.egg-info/entry_points.txt +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands_agent_server.egg-info/requires.txt +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands_agent_server.egg-info/top_level.txt +0 -0
- {openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openhands-agent-server
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.23.1
|
|
4
4
|
Summary: OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent
|
|
5
5
|
Project-URL: Source, https://github.com/OpenHands/software-agent-sdk
|
|
6
6
|
Project-URL: Homepage, https://github.com/OpenHands/software-agent-sdk
|
{openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/api.py
RENAMED
|
@@ -3,7 +3,7 @@ import os
|
|
|
3
3
|
import tempfile
|
|
4
4
|
import traceback
|
|
5
5
|
from collections.abc import AsyncIterator, Sequence
|
|
6
|
-
from contextlib import asynccontextmanager
|
|
6
|
+
from contextlib import asynccontextmanager, suppress
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
from typing import Any
|
|
9
9
|
from urllib.parse import urlparse
|
|
@@ -17,6 +17,7 @@ from starlette.requests import Request
|
|
|
17
17
|
|
|
18
18
|
from openhands.agent_server.auth_router import auth_router
|
|
19
19
|
from openhands.agent_server.bash_router import bash_router
|
|
20
|
+
from openhands.agent_server.bash_service import get_default_bash_event_service
|
|
20
21
|
from openhands.agent_server.cloud_proxy_router import cloud_proxy_router
|
|
21
22
|
from openhands.agent_server.config import (
|
|
22
23
|
Config,
|
|
@@ -38,7 +39,8 @@ from openhands.agent_server.file_router import file_router
|
|
|
38
39
|
from openhands.agent_server.git_router import git_router
|
|
39
40
|
from openhands.agent_server.hooks_router import hooks_router
|
|
40
41
|
from openhands.agent_server.llm_router import llm_router
|
|
41
|
-
from openhands.agent_server.
|
|
42
|
+
from openhands.agent_server.mcp_router import mcp_router
|
|
43
|
+
from openhands.agent_server.middleware import CORSDispatcher
|
|
42
44
|
from openhands.agent_server.profiles_router import profiles_router
|
|
43
45
|
from openhands.agent_server.server_details_router import (
|
|
44
46
|
get_server_info,
|
|
@@ -53,6 +55,7 @@ from openhands.agent_server.tool_router import tool_router
|
|
|
53
55
|
from openhands.agent_server.vscode_router import vscode_router
|
|
54
56
|
from openhands.agent_server.vscode_service import get_vscode_service
|
|
55
57
|
from openhands.agent_server.workspace_router import workspace_router
|
|
58
|
+
from openhands.agent_server.workspaces_router import workspaces_router
|
|
56
59
|
from openhands.sdk.logger import DEBUG, get_logger
|
|
57
60
|
from openhands.sdk.utils.redact import sanitize_dict
|
|
58
61
|
from openhands.tools.terminal.constants import TMUX_SOCKET_NAME
|
|
@@ -186,9 +189,28 @@ async def api_lifespan(api: FastAPI) -> AsyncIterator[None]:
|
|
|
186
189
|
async with service:
|
|
187
190
|
# Store the initialized service in app state for dependency injection
|
|
188
191
|
api.state.conversation_service = service
|
|
192
|
+
|
|
193
|
+
config = api.state.config
|
|
194
|
+
retention_task: asyncio.Task | None = None
|
|
195
|
+
if config.bash_events_retention_seconds is not None:
|
|
196
|
+
retention_task = asyncio.create_task(
|
|
197
|
+
get_default_bash_event_service().run_retention_cleanup_loop(
|
|
198
|
+
config.bash_events_retention_seconds
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
logger.info(
|
|
202
|
+
"Bash events retention cleanup started (retention: %ds)",
|
|
203
|
+
config.bash_events_retention_seconds,
|
|
204
|
+
)
|
|
205
|
+
|
|
189
206
|
try:
|
|
190
207
|
yield
|
|
191
208
|
finally:
|
|
209
|
+
if retention_task is not None:
|
|
210
|
+
retention_task.cancel()
|
|
211
|
+
with suppress(asyncio.CancelledError):
|
|
212
|
+
await retention_task
|
|
213
|
+
|
|
192
214
|
# Define async functions for stopping each service
|
|
193
215
|
async def stop_vscode_service():
|
|
194
216
|
if vscode_service is not None:
|
|
@@ -288,7 +310,9 @@ def _add_api_routes(app: FastAPI, config: Config) -> None:
|
|
|
288
310
|
api_router.include_router(skills_router)
|
|
289
311
|
api_router.include_router(hooks_router)
|
|
290
312
|
api_router.include_router(llm_router)
|
|
313
|
+
api_router.include_router(mcp_router)
|
|
291
314
|
api_router.include_router(settings_router)
|
|
315
|
+
api_router.include_router(workspaces_router)
|
|
292
316
|
api_router.include_router(profiles_router)
|
|
293
317
|
api_router.include_router(cloud_proxy_router)
|
|
294
318
|
# /api/auth/* mints workspace cookies and requires the header to bootstrap,
|
|
@@ -515,7 +539,7 @@ def create_app(config: Config | None = None) -> FastAPI:
|
|
|
515
539
|
|
|
516
540
|
_add_api_routes(app, config)
|
|
517
541
|
_setup_static_files(app, config)
|
|
518
|
-
app.add_middleware(
|
|
542
|
+
app.add_middleware(CORSDispatcher, allow_origins=config.allow_cors_origins)
|
|
519
543
|
_add_exception_handlers(app)
|
|
520
544
|
|
|
521
545
|
return app
|
|
@@ -4,7 +4,7 @@ import json
|
|
|
4
4
|
import os
|
|
5
5
|
import signal
|
|
6
6
|
from dataclasses import dataclass, field
|
|
7
|
-
from datetime import datetime
|
|
7
|
+
from datetime import datetime, timedelta
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
from uuid import UUID
|
|
10
10
|
|
|
@@ -18,7 +18,7 @@ from openhands.agent_server.models import (
|
|
|
18
18
|
)
|
|
19
19
|
from openhands.agent_server.pub_sub import PubSub, Subscriber
|
|
20
20
|
from openhands.sdk.logger import get_logger
|
|
21
|
-
from openhands.sdk.utils import sanitized_env
|
|
21
|
+
from openhands.sdk.utils import sanitized_env, utc_now
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
logger = get_logger(__name__)
|
|
@@ -344,6 +344,76 @@ class BashEventService:
|
|
|
344
344
|
self._save_event_to_file(error_output)
|
|
345
345
|
await self._pub_sub(error_output)
|
|
346
346
|
|
|
347
|
+
def delete_events_older_than(self, cutoff: datetime) -> int:
|
|
348
|
+
"""Delete bash event files with a recorded timestamp older than ``cutoff``.
|
|
349
|
+
|
|
350
|
+
This is a synchronous method — all operations are blocking filesystem
|
|
351
|
+
I/O. Callers on the asyncio event loop should use
|
|
352
|
+
``await asyncio.to_thread(service.delete_events_older_than, cutoff)``
|
|
353
|
+
to avoid stalling the loop.
|
|
354
|
+
|
|
355
|
+
File names are prefixed with ``YYYYMMDDHHMMSS`` in ascending sort order,
|
|
356
|
+
so scanning stops as soon as a file at or after the cutoff is reached.
|
|
357
|
+
|
|
358
|
+
Returns:
|
|
359
|
+
int: The number of event files deleted.
|
|
360
|
+
"""
|
|
361
|
+
cutoff_str = self._timestamp_to_str(cutoff)
|
|
362
|
+
files = self._get_event_files_by_pattern("*") # ascending chronological order
|
|
363
|
+
count = 0
|
|
364
|
+
for path in files:
|
|
365
|
+
if path.name >= cutoff_str:
|
|
366
|
+
break # remaining files are at or newer than cutoff
|
|
367
|
+
try:
|
|
368
|
+
path.unlink(missing_ok=True)
|
|
369
|
+
count += 1
|
|
370
|
+
except Exception as e:
|
|
371
|
+
logger.warning("Failed to delete bash event file %s: %s", path, e)
|
|
372
|
+
if count:
|
|
373
|
+
logger.info(
|
|
374
|
+
"Deleted %d bash event file(s) older than %s", count, cutoff_str
|
|
375
|
+
)
|
|
376
|
+
return count
|
|
377
|
+
|
|
378
|
+
async def run_retention_cleanup_loop(
|
|
379
|
+
self,
|
|
380
|
+
retention_seconds: int,
|
|
381
|
+
interval_seconds: float | None = None,
|
|
382
|
+
) -> None:
|
|
383
|
+
"""Periodically purge bash event files older than ``retention_seconds``.
|
|
384
|
+
|
|
385
|
+
Runs until cancelled (e.g. during application shutdown). Cleanup runs
|
|
386
|
+
immediately on entry so that files accumulated across a server restart
|
|
387
|
+
are purged without waiting for the first interval to elapse.
|
|
388
|
+
|
|
389
|
+
Blocking filesystem work is dispatched to a thread via
|
|
390
|
+
``asyncio.to_thread`` to keep the event loop free.
|
|
391
|
+
|
|
392
|
+
Args:
|
|
393
|
+
retention_seconds: Age threshold in seconds; older files are deleted.
|
|
394
|
+
interval_seconds: How often to run the cleanup. Defaults to
|
|
395
|
+
``max(60, retention_seconds / 2)``. Pass a smaller value in
|
|
396
|
+
tests to avoid long waits.
|
|
397
|
+
"""
|
|
398
|
+
interval = (
|
|
399
|
+
interval_seconds
|
|
400
|
+
if interval_seconds is not None
|
|
401
|
+
else max(60.0, retention_seconds / 2)
|
|
402
|
+
)
|
|
403
|
+
while True:
|
|
404
|
+
try:
|
|
405
|
+
cutoff = utc_now() - timedelta(seconds=retention_seconds)
|
|
406
|
+
await asyncio.to_thread(self.delete_events_older_than, cutoff)
|
|
407
|
+
except Exception as e:
|
|
408
|
+
logger.warning("Bash events retention cleanup error: %s", e)
|
|
409
|
+
# Brief back-off to prevent log flooding if the failure is persistent
|
|
410
|
+
# (e.g. permission error, full disk). Cap at the normal interval so
|
|
411
|
+
# we don't over-delay in low-retention configurations.
|
|
412
|
+
await asyncio.sleep(min(interval, 60.0))
|
|
413
|
+
# Always sleep the full interval after the error back-off, so total
|
|
414
|
+
# wait on error = min(interval, 60) + interval ≈ 2× normal cadence.
|
|
415
|
+
await asyncio.sleep(interval)
|
|
416
|
+
|
|
347
417
|
async def subscribe_to_events(self, subscriber: Subscriber[BashEventBase]) -> UUID:
|
|
348
418
|
"""Subscribe to bash events.
|
|
349
419
|
|
{openhands_agent_server-1.22.1 → openhands_agent_server-1.23.1}/openhands/agent_server/config.py
RENAMED
|
@@ -120,8 +120,10 @@ class Config(BaseModel):
|
|
|
120
120
|
allow_cors_origins: list[str] = Field(
|
|
121
121
|
default_factory=list,
|
|
122
122
|
description=(
|
|
123
|
-
"
|
|
124
|
-
"always
|
|
123
|
+
"CORS origins permitted by this server. Localhost / 127.0.0.1 "
|
|
124
|
+
"and ``DOCKER_HOST_ADDR`` are always allowed. Does not apply to "
|
|
125
|
+
"the workspace cookie routes, which accept any origin — see "
|
|
126
|
+
"``middleware.py``."
|
|
125
127
|
),
|
|
126
128
|
)
|
|
127
129
|
conversations_path: Path = Field(
|
|
@@ -137,6 +139,19 @@ class Config(BaseModel):
|
|
|
137
139
|
"Defaults to 'workspace/bash_events'."
|
|
138
140
|
),
|
|
139
141
|
)
|
|
142
|
+
bash_events_retention_seconds: int | None = Field(
|
|
143
|
+
default=None,
|
|
144
|
+
gt=0,
|
|
145
|
+
description=(
|
|
146
|
+
"How long bash event files are retained on disk, in seconds. "
|
|
147
|
+
"A background task purges events older than this window on a "
|
|
148
|
+
"rolling basis. None (default) retains events indefinitely. "
|
|
149
|
+
"Should be set higher than the longest expected command timeout: "
|
|
150
|
+
"a command whose BashCommand file is purged mid-execution will "
|
|
151
|
+
"complete normally, but its on-disk event history will be "
|
|
152
|
+
"incomplete. A value >= 2x max command timeout avoids this."
|
|
153
|
+
),
|
|
154
|
+
)
|
|
140
155
|
static_files_path: Path | None = Field(
|
|
141
156
|
default=None,
|
|
142
157
|
description=(
|
|
@@ -22,6 +22,7 @@ from openhands.agent_server._secrets_exposure import (
|
|
|
22
22
|
from openhands.agent_server.conversation_service import ConversationService
|
|
23
23
|
from openhands.agent_server.dependencies import get_conversation_service
|
|
24
24
|
from openhands.agent_server.models import (
|
|
25
|
+
INCLUDE_SKILLS_PARAM_TITLE,
|
|
25
26
|
AgentResponseResult,
|
|
26
27
|
AskAgentRequest,
|
|
27
28
|
AskAgentResponse,
|
|
@@ -36,6 +37,7 @@ from openhands.agent_server.models import (
|
|
|
36
37
|
Success,
|
|
37
38
|
UpdateConversationRequest,
|
|
38
39
|
UpdateSecretsRequest,
|
|
40
|
+
trim_conversation_response_skills,
|
|
39
41
|
)
|
|
40
42
|
from openhands.sdk import LLM, Agent, TextContent
|
|
41
43
|
from openhands.sdk.conversation.state import ConversationExecutionStatus
|
|
@@ -86,14 +88,28 @@ async def search_conversations(
|
|
|
86
88
|
ConversationSortOrder,
|
|
87
89
|
Query(title="Sort order for conversations"),
|
|
88
90
|
] = ConversationSortOrder.CREATED_AT_DESC,
|
|
91
|
+
include_skills: Annotated[bool, Query(title=INCLUDE_SKILLS_PARAM_TITLE)] = False,
|
|
89
92
|
conversation_service: ConversationService = Depends(get_conversation_service),
|
|
90
93
|
) -> ConversationPage:
|
|
91
94
|
"""Search / List conversations"""
|
|
92
95
|
assert limit > 0
|
|
93
96
|
assert limit <= 100
|
|
94
|
-
|
|
97
|
+
page = await conversation_service.search_conversations(
|
|
95
98
|
page_id, limit, status, sort_order
|
|
96
99
|
)
|
|
100
|
+
if not include_skills:
|
|
101
|
+
# ``model_copy`` rather than in-place mutation so we never
|
|
102
|
+
# write back into whatever the upstream service handed us
|
|
103
|
+
# (matters for services that cache their return value,
|
|
104
|
+
# including the ``AsyncMock`` used in route tests).
|
|
105
|
+
page = page.model_copy(
|
|
106
|
+
update={
|
|
107
|
+
"items": [
|
|
108
|
+
trim_conversation_response_skills(item) for item in page.items
|
|
109
|
+
]
|
|
110
|
+
}
|
|
111
|
+
)
|
|
112
|
+
return page
|
|
97
113
|
|
|
98
114
|
|
|
99
115
|
@conversation_router.get("/count")
|
|
@@ -114,12 +130,15 @@ async def count_conversations(
|
|
|
114
130
|
)
|
|
115
131
|
async def get_conversation(
|
|
116
132
|
conversation_id: UUID,
|
|
133
|
+
include_skills: Annotated[bool, Query(title=INCLUDE_SKILLS_PARAM_TITLE)] = False,
|
|
117
134
|
conversation_service: ConversationService = Depends(get_conversation_service),
|
|
118
135
|
) -> ConversationInfo:
|
|
119
136
|
"""Given an id, get a conversation"""
|
|
120
137
|
conversation = await conversation_service.get_conversation(conversation_id)
|
|
121
138
|
if conversation is None:
|
|
122
139
|
raise HTTPException(status.HTTP_404_NOT_FOUND)
|
|
140
|
+
if not include_skills:
|
|
141
|
+
conversation = trim_conversation_response_skills(conversation)
|
|
123
142
|
return conversation
|
|
124
143
|
|
|
125
144
|
|
|
@@ -147,12 +166,18 @@ async def get_conversation_agent_final_response(
|
|
|
147
166
|
@conversation_router.get("")
|
|
148
167
|
async def batch_get_conversations(
|
|
149
168
|
ids: Annotated[list[UUID], Query()],
|
|
169
|
+
include_skills: Annotated[bool, Query(title=INCLUDE_SKILLS_PARAM_TITLE)] = False,
|
|
150
170
|
conversation_service: ConversationService = Depends(get_conversation_service),
|
|
151
171
|
) -> list[ConversationInfo | None]:
|
|
152
172
|
"""Get a batch of conversations given their ids, returning null for
|
|
153
173
|
any missing item"""
|
|
154
174
|
assert len(ids) < 100
|
|
155
175
|
conversations = await conversation_service.batch_get_conversations(ids)
|
|
176
|
+
if not include_skills:
|
|
177
|
+
return [
|
|
178
|
+
trim_conversation_response_skills(c) if c is not None else None
|
|
179
|
+
for c in conversations
|
|
180
|
+
]
|
|
156
181
|
return conversations
|
|
157
182
|
|
|
158
183
|
|
|
@@ -165,11 +190,14 @@ async def start_conversation(
|
|
|
165
190
|
StartConversationRequest, Body(examples=START_CONVERSATION_EXAMPLES)
|
|
166
191
|
],
|
|
167
192
|
response: Response,
|
|
193
|
+
include_skills: Annotated[bool, Query(title=INCLUDE_SKILLS_PARAM_TITLE)] = False,
|
|
168
194
|
conversation_service: ConversationService = Depends(get_conversation_service),
|
|
169
195
|
) -> ConversationInfo:
|
|
170
196
|
"""Start a conversation in the local environment."""
|
|
171
197
|
info, is_new = await conversation_service.start_conversation(request)
|
|
172
198
|
response.status_code = status.HTTP_201_CREATED if is_new else status.HTTP_200_OK
|
|
199
|
+
if not include_skills:
|
|
200
|
+
info = trim_conversation_response_skills(info)
|
|
173
201
|
return info
|
|
174
202
|
|
|
175
203
|
|
|
@@ -187,6 +215,26 @@ async def pause_conversation(
|
|
|
187
215
|
return Success()
|
|
188
216
|
|
|
189
217
|
|
|
218
|
+
@conversation_router.post(
|
|
219
|
+
"/{conversation_id}/interrupt",
|
|
220
|
+
responses={404: {"description": "Item not found"}},
|
|
221
|
+
)
|
|
222
|
+
async def interrupt_conversation(
|
|
223
|
+
conversation_id: UUID,
|
|
224
|
+
conversation_service: ConversationService = Depends(get_conversation_service),
|
|
225
|
+
) -> Success:
|
|
226
|
+
"""Immediately interrupt a running conversation.
|
|
227
|
+
|
|
228
|
+
Unlike ``/pause``, which waits for the current LLM call to finish,
|
|
229
|
+
``/interrupt`` cancels the in-flight request so the effect is instant.
|
|
230
|
+
The conversation transitions to *paused* and can be resumed later.
|
|
231
|
+
"""
|
|
232
|
+
interrupted = await conversation_service.interrupt_conversation(conversation_id)
|
|
233
|
+
if not interrupted:
|
|
234
|
+
raise HTTPException(status.HTTP_400_BAD_REQUEST)
|
|
235
|
+
return Success()
|
|
236
|
+
|
|
237
|
+
|
|
190
238
|
@conversation_router.delete(
|
|
191
239
|
"/{conversation_id}", responses={404: {"description": "Item not found"}}
|
|
192
240
|
)
|
|
@@ -407,6 +455,7 @@ async def condense_conversation(
|
|
|
407
455
|
async def fork_conversation(
|
|
408
456
|
conversation_id: UUID,
|
|
409
457
|
request: Annotated[ForkConversationRequest, Body()] = ForkConversationRequest(), # noqa: B008
|
|
458
|
+
include_skills: Annotated[bool, Query(title=INCLUDE_SKILLS_PARAM_TITLE)] = False,
|
|
410
459
|
conversation_service: ConversationService = Depends(get_conversation_service),
|
|
411
460
|
) -> ConversationInfo:
|
|
412
461
|
"""Fork a conversation, deep-copying its event history.
|
|
@@ -432,4 +481,6 @@ async def fork_conversation(
|
|
|
432
481
|
status.HTTP_404_NOT_FOUND,
|
|
433
482
|
detail="Source conversation not found",
|
|
434
483
|
)
|
|
484
|
+
if not include_skills:
|
|
485
|
+
info = trim_conversation_response_skills(info)
|
|
435
486
|
return info
|
|
@@ -14,11 +14,13 @@ from pydantic import SecretStr
|
|
|
14
14
|
from openhands.agent_server.conversation_service import ConversationService
|
|
15
15
|
from openhands.agent_server.dependencies import get_conversation_service
|
|
16
16
|
from openhands.agent_server.models import (
|
|
17
|
+
INCLUDE_SKILLS_PARAM_TITLE,
|
|
17
18
|
ACPConversationInfo,
|
|
18
19
|
ACPConversationPage,
|
|
19
20
|
ConversationSortOrder,
|
|
20
21
|
SendMessageRequest,
|
|
21
22
|
StartACPConversationRequest,
|
|
23
|
+
trim_conversation_response_skills,
|
|
22
24
|
)
|
|
23
25
|
from openhands.sdk import LLM, Agent, TextContent
|
|
24
26
|
from openhands.sdk.agent.acp_agent import ACPAgent
|
|
@@ -76,6 +78,7 @@ async def search_acp_conversations(
|
|
|
76
78
|
ConversationSortOrder,
|
|
77
79
|
Query(title="Sort order for conversations"),
|
|
78
80
|
] = ConversationSortOrder.CREATED_AT_DESC,
|
|
81
|
+
include_skills: Annotated[bool, Query(title=INCLUDE_SKILLS_PARAM_TITLE)] = False,
|
|
79
82
|
conversation_service: ConversationService = Depends(get_conversation_service),
|
|
80
83
|
) -> ACPConversationPage:
|
|
81
84
|
"""Search conversations using the ACP-capable contract.
|
|
@@ -85,9 +88,18 @@ async def search_acp_conversations(
|
|
|
85
88
|
"""
|
|
86
89
|
assert limit > 0
|
|
87
90
|
assert limit <= 100
|
|
88
|
-
|
|
91
|
+
page = await conversation_service.search_acp_conversations(
|
|
89
92
|
page_id, limit, status, sort_order
|
|
90
93
|
)
|
|
94
|
+
if not include_skills:
|
|
95
|
+
page = page.model_copy(
|
|
96
|
+
update={
|
|
97
|
+
"items": [
|
|
98
|
+
trim_conversation_response_skills(item) for item in page.items
|
|
99
|
+
]
|
|
100
|
+
}
|
|
101
|
+
)
|
|
102
|
+
return page
|
|
91
103
|
|
|
92
104
|
|
|
93
105
|
@conversation_router_acp.get("/count", deprecated=True)
|
|
@@ -113,6 +125,7 @@ async def count_acp_conversations(
|
|
|
113
125
|
)
|
|
114
126
|
async def get_acp_conversation(
|
|
115
127
|
conversation_id: UUID,
|
|
128
|
+
include_skills: Annotated[bool, Query(title=INCLUDE_SKILLS_PARAM_TITLE)] = False,
|
|
116
129
|
conversation_service: ConversationService = Depends(get_conversation_service),
|
|
117
130
|
) -> ACPConversationInfo:
|
|
118
131
|
"""Get a conversation using the ACP-capable contract.
|
|
@@ -123,12 +136,15 @@ async def get_acp_conversation(
|
|
|
123
136
|
conversation = await conversation_service.get_acp_conversation(conversation_id)
|
|
124
137
|
if conversation is None:
|
|
125
138
|
raise HTTPException(status.HTTP_404_NOT_FOUND)
|
|
139
|
+
if not include_skills:
|
|
140
|
+
conversation = trim_conversation_response_skills(conversation)
|
|
126
141
|
return conversation
|
|
127
142
|
|
|
128
143
|
|
|
129
144
|
@conversation_router_acp.get("", deprecated=True)
|
|
130
145
|
async def batch_get_acp_conversations(
|
|
131
146
|
ids: Annotated[list[UUID], Query()],
|
|
147
|
+
include_skills: Annotated[bool, Query(title=INCLUDE_SKILLS_PARAM_TITLE)] = False,
|
|
132
148
|
conversation_service: ConversationService = Depends(get_conversation_service),
|
|
133
149
|
) -> list[ACPConversationInfo | None]:
|
|
134
150
|
"""Batch get conversations using the ACP-capable contract.
|
|
@@ -137,7 +153,13 @@ async def batch_get_acp_conversations(
|
|
|
137
153
|
Use ``/api/conversations`` instead.
|
|
138
154
|
"""
|
|
139
155
|
assert len(ids) < 100
|
|
140
|
-
|
|
156
|
+
conversations = await conversation_service.batch_get_acp_conversations(ids)
|
|
157
|
+
if not include_skills:
|
|
158
|
+
return [
|
|
159
|
+
trim_conversation_response_skills(c) if c is not None else None
|
|
160
|
+
for c in conversations
|
|
161
|
+
]
|
|
162
|
+
return conversations
|
|
141
163
|
|
|
142
164
|
|
|
143
165
|
@conversation_router_acp.post("", deprecated=True)
|
|
@@ -147,6 +169,7 @@ async def start_acp_conversation(
|
|
|
147
169
|
Body(examples=START_ACP_CONVERSATION_EXAMPLES),
|
|
148
170
|
],
|
|
149
171
|
response: Response,
|
|
172
|
+
include_skills: Annotated[bool, Query(title=INCLUDE_SKILLS_PARAM_TITLE)] = False,
|
|
150
173
|
conversation_service: ConversationService = Depends(get_conversation_service),
|
|
151
174
|
) -> ACPConversationInfo:
|
|
152
175
|
"""Start a conversation using the ACP-capable contract.
|
|
@@ -157,4 +180,6 @@ async def start_acp_conversation(
|
|
|
157
180
|
"""
|
|
158
181
|
info, is_new = await conversation_service.start_acp_conversation(request)
|
|
159
182
|
response.status_code = status.HTTP_201_CREATED if is_new else status.HTTP_200_OK
|
|
183
|
+
if not include_skills:
|
|
184
|
+
info = trim_conversation_response_skills(info)
|
|
160
185
|
return info
|
|
@@ -649,6 +649,25 @@ class ConversationService:
|
|
|
649
649
|
await self._notify_conversation_webhooks(conversation_info)
|
|
650
650
|
return bool(event_service)
|
|
651
651
|
|
|
652
|
+
async def interrupt_conversation(self, conversation_id: UUID) -> bool:
|
|
653
|
+
"""Immediately cancel an in-flight LLM call for a conversation.
|
|
654
|
+
|
|
655
|
+
Unlike :meth:`pause_conversation`, which waits for the current
|
|
656
|
+
LLM request to finish, this cancels the running ``arun()`` task
|
|
657
|
+
so the interruption takes effect mid-stream.
|
|
658
|
+
"""
|
|
659
|
+
if self._event_services is None:
|
|
660
|
+
raise ValueError("inactive_service")
|
|
661
|
+
event_service = self._event_services.get(conversation_id)
|
|
662
|
+
if event_service:
|
|
663
|
+
await event_service.interrupt()
|
|
664
|
+
state = await event_service.get_state()
|
|
665
|
+
conversation_info = _compose_webhook_conversation_info(
|
|
666
|
+
event_service.stored, state
|
|
667
|
+
)
|
|
668
|
+
await self._notify_conversation_webhooks(conversation_info)
|
|
669
|
+
return bool(event_service)
|
|
670
|
+
|
|
652
671
|
async def resume_conversation(self, conversation_id: UUID) -> bool:
|
|
653
672
|
if self._event_services is None:
|
|
654
673
|
raise ValueError("inactive_service")
|
|
@@ -18,6 +18,7 @@ from openhands.agent_server.models import (
|
|
|
18
18
|
)
|
|
19
19
|
from openhands.agent_server.pub_sub import PubSub, Subscriber
|
|
20
20
|
from openhands.sdk import LLM, AgentBase, Event, Message, get_logger
|
|
21
|
+
from openhands.sdk.conversation.base import BaseConversation
|
|
21
22
|
from openhands.sdk.conversation.impl.local_conversation import LocalConversation
|
|
22
23
|
from openhands.sdk.conversation.response_utils import get_agent_final_response
|
|
23
24
|
from openhands.sdk.conversation.secret_registry import SecretValue
|
|
@@ -67,6 +68,9 @@ class EventService:
|
|
|
67
68
|
default_factory=lambda: PubSub[Event](max_subscribers=50), init=False
|
|
68
69
|
)
|
|
69
70
|
_run_task: asyncio.Task | None = field(default=None, init=False)
|
|
71
|
+
# Set when a send_message(run=True) is rejected because a run is still
|
|
72
|
+
# wrapping up; consumed by _run_and_publish to re-run the stranded message.
|
|
73
|
+
_rerun_requested: bool = field(default=False, init=False)
|
|
70
74
|
_run_lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False)
|
|
71
75
|
_callback_wrapper: AsyncCallbackWrapper | None = field(default=None, init=False)
|
|
72
76
|
_lease: ConversationLease | None = field(default=None, init=False)
|
|
@@ -418,9 +422,19 @@ class EventService:
|
|
|
418
422
|
loop = asyncio.get_running_loop()
|
|
419
423
|
await loop.run_in_executor(None, self._conversation.send_message, message)
|
|
420
424
|
if run:
|
|
421
|
-
|
|
422
|
-
with suppress(ValueError):
|
|
425
|
+
try:
|
|
423
426
|
await self.run()
|
|
427
|
+
except ValueError as e:
|
|
428
|
+
# run() refused. If a run is still wrapping up (its
|
|
429
|
+
# wait_for_pending tail), the message we just appended won't be
|
|
430
|
+
# picked up by it, so record explicit run intent for
|
|
431
|
+
# _run_and_publish to honor once that task clears. Tracking the
|
|
432
|
+
# request — rather than inferring it later from an IDLE status —
|
|
433
|
+
# is what keeps a deliberate run=False append, or an IDLE reached
|
|
434
|
+
# via another path, from triggering an unwanted run.
|
|
435
|
+
# "inactive_service" is terminal and must not re-arm.
|
|
436
|
+
if str(e) == "conversation_already_running":
|
|
437
|
+
self._rerun_requested = True
|
|
424
438
|
|
|
425
439
|
async def subscribe_to_events(self, subscriber: Subscriber[Event]) -> UUID:
|
|
426
440
|
subscriber_id = self._pub_sub.subscribe(subscriber)
|
|
@@ -522,13 +536,29 @@ class EventService:
|
|
|
522
536
|
"""Configure stats update callbacks to stream stats changes via events."""
|
|
523
537
|
|
|
524
538
|
def stats_callback() -> None:
|
|
525
|
-
"""Callback to emit stats updates.
|
|
539
|
+
"""Callback to emit stats updates.
|
|
540
|
+
|
|
541
|
+
Invoked synchronously by ``Telemetry.on_response`` (regular
|
|
542
|
+
Agent path) and ``ACPAgent._record_usage`` (ACP path) — both
|
|
543
|
+
run inside ``LocalConversation.run()``'s ``with self._state:``
|
|
544
|
+
block, so the caller already owns the conversation state lock.
|
|
545
|
+
|
|
546
|
+
DO NOT re-acquire the state lock here (``with state:``). It
|
|
547
|
+
looks safe — ``FIFOLock`` documents itself as reentrant — but
|
|
548
|
+
on the ACP code path it deadlocks (silently) before the rest
|
|
549
|
+
of ``step()`` can emit the assistant's FinishAction +
|
|
550
|
+
ObservationEvent, leaving every conversation hung in
|
|
551
|
+
``running`` status forever. ``_emit_event_from_thread`` below
|
|
552
|
+
already acquires the lock on the executor thread before
|
|
553
|
+
persisting the event; that's the only place serialization
|
|
554
|
+
needs the lock anyway.
|
|
555
|
+
"""
|
|
526
556
|
# Publish only the stats field to avoid sending entire state
|
|
527
557
|
if not self._conversation:
|
|
528
558
|
return
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
559
|
+
event = ConversationStateUpdateEvent(
|
|
560
|
+
key="stats", value=self._conversation._state.stats
|
|
561
|
+
)
|
|
532
562
|
self._emit_event_from_thread(event)
|
|
533
563
|
|
|
534
564
|
for llm in agent.get_all_llms():
|
|
@@ -645,6 +675,7 @@ class EventService:
|
|
|
645
675
|
cipher=self.cipher,
|
|
646
676
|
hook_config=self.stored.hook_config,
|
|
647
677
|
tags=self.stored.tags,
|
|
678
|
+
user_id=self.stored.user_id,
|
|
648
679
|
)
|
|
649
680
|
|
|
650
681
|
conversation.set_confirmation_policy(self.stored.confirmation_policy)
|
|
@@ -706,8 +737,11 @@ class EventService:
|
|
|
706
737
|
"""Run the conversation asynchronously in the background.
|
|
707
738
|
|
|
708
739
|
This method starts the conversation run in a background task and returns
|
|
709
|
-
immediately.
|
|
710
|
-
|
|
740
|
+
immediately. When possible, the conversation is driven via its native
|
|
741
|
+
``arun()`` coroutine so LLM I/O does not tie up a thread-pool worker.
|
|
742
|
+
For conversations that do not expose ``arun()`` (e.g., custom
|
|
743
|
+
subclasses), the synchronous ``run()`` is executed in the thread pool as
|
|
744
|
+
before.
|
|
711
745
|
|
|
712
746
|
Raises:
|
|
713
747
|
ValueError: If the service is inactive or conversation is already running.
|
|
@@ -735,7 +769,28 @@ class EventService:
|
|
|
735
769
|
|
|
736
770
|
async def _run_and_publish():
|
|
737
771
|
try:
|
|
738
|
-
|
|
772
|
+
# Prefer the native async path when available so the event
|
|
773
|
+
# loop is free during LLM I/O. Fall back to thread-pool
|
|
774
|
+
# execution for backward compatibility.
|
|
775
|
+
#
|
|
776
|
+
# Both guards are required:
|
|
777
|
+
# • iscoroutinefunction – filters out non-async objects
|
|
778
|
+
# (e.g. MagicMock in tests).
|
|
779
|
+
# • override check – BaseConversation defines a default
|
|
780
|
+
# ``async def arun()`` that delegates to sync ``run()``,
|
|
781
|
+
# so iscoroutinefunction alone is always True for real
|
|
782
|
+
# subclasses. We detect an *actual* override to avoid
|
|
783
|
+
# running a sync-only subclass on the event loop.
|
|
784
|
+
arun = getattr(conversation, "arun", None)
|
|
785
|
+
has_native_arun = (
|
|
786
|
+
arun is not None
|
|
787
|
+
and asyncio.iscoroutinefunction(arun)
|
|
788
|
+
and type(conversation).arun is not BaseConversation.arun
|
|
789
|
+
)
|
|
790
|
+
if has_native_arun:
|
|
791
|
+
await conversation.arun()
|
|
792
|
+
else:
|
|
793
|
+
await loop.run_in_executor(self._run_executor, conversation.run)
|
|
739
794
|
except Exception:
|
|
740
795
|
logger.exception("Error during conversation run")
|
|
741
796
|
finally:
|
|
@@ -753,6 +808,26 @@ class EventService:
|
|
|
753
808
|
self._run_task = None
|
|
754
809
|
await self._publish_state_update()
|
|
755
810
|
|
|
811
|
+
# Re-arm a run for input stranded while this task was
|
|
812
|
+
# wrapping up. A send_message(run=True) that arrived during
|
|
813
|
+
# the wait_for_pending() tail above had its run() rejected as
|
|
814
|
+
# "conversation_already_running" and suppressed, setting
|
|
815
|
+
# _rerun_requested. Honor it only while the conversation is
|
|
816
|
+
# still IDLE — i.e. that message is genuinely pending. If the
|
|
817
|
+
# run loop was still alive it already absorbed the message
|
|
818
|
+
# (LocalConversation.run() keeps looping on FINISHED) and we
|
|
819
|
+
# are FINISHED here, so the IDLE guard avoids a redundant run.
|
|
820
|
+
# A deliberate run=False append, or an IDLE reached via
|
|
821
|
+
# another path, never sets the flag.
|
|
822
|
+
if self._rerun_requested:
|
|
823
|
+
self._rerun_requested = False
|
|
824
|
+
if (
|
|
825
|
+
await self._get_execution_status()
|
|
826
|
+
== ConversationExecutionStatus.IDLE
|
|
827
|
+
):
|
|
828
|
+
with suppress(ValueError):
|
|
829
|
+
await self.run()
|
|
830
|
+
|
|
756
831
|
# Create task but don't await it - runs in background
|
|
757
832
|
self._run_task = asyncio.create_task(_run_and_publish())
|
|
758
833
|
|
|
@@ -787,6 +862,28 @@ class EventService:
|
|
|
787
862
|
# Publish state update after pause to ensure stats are updated
|
|
788
863
|
await self._publish_state_update()
|
|
789
864
|
|
|
865
|
+
async def interrupt(self):
|
|
866
|
+
"""Immediately cancel an in-flight async LLM call.
|
|
867
|
+
|
|
868
|
+
Delegates to :meth:`LocalConversation.interrupt` which cancels the
|
|
869
|
+
``arun()`` task. If no async run is in progress the call falls
|
|
870
|
+
back to :meth:`pause`.
|
|
871
|
+
"""
|
|
872
|
+
if self._conversation:
|
|
873
|
+
self._conversation.interrupt()
|
|
874
|
+
# Wait for the run task to finish so we can publish the final
|
|
875
|
+
# state update (PAUSED + InterruptEvent) cleanly.
|
|
876
|
+
if self._run_task is not None and not self._run_task.done():
|
|
877
|
+
with suppress(Exception):
|
|
878
|
+
await asyncio.wait_for(self._run_task, timeout=5.0)
|
|
879
|
+
# Only clear _run_task if it actually finished; if
|
|
880
|
+
# wait_for timed out the task may still be running and
|
|
881
|
+
# clearing prematurely would allow a second run() to
|
|
882
|
+
# start while the first is still in progress.
|
|
883
|
+
if self._run_task is not None and self._run_task.done():
|
|
884
|
+
self._run_task = None
|
|
885
|
+
await self._publish_state_update()
|
|
886
|
+
|
|
790
887
|
async def update_secrets(self, secrets: dict[str, SecretValue]):
|
|
791
888
|
"""Update secrets in the conversation."""
|
|
792
889
|
if not self._conversation:
|
|
@@ -832,8 +929,15 @@ class EventService:
|
|
|
832
929
|
logger.warning(
|
|
833
930
|
"Failed to pause conversation during close", exc_info=True
|
|
834
931
|
)
|
|
932
|
+
# Cancel the run task so arun()'s CancelledError handler can
|
|
933
|
+
# transition to PAUSED cleanly. For the legacy thread-pool
|
|
934
|
+
# path the underlying thread keeps running but the wrapper
|
|
935
|
+
# task still settles, unblocking the wait below.
|
|
936
|
+
self._run_task.cancel()
|
|
835
937
|
try:
|
|
836
938
|
await asyncio.wait_for(self._run_task, timeout=10.0)
|
|
939
|
+
except asyncio.CancelledError:
|
|
940
|
+
pass # Expected after cancel()
|
|
837
941
|
except Exception as exc:
|
|
838
942
|
logger.warning("Run task did not exit cleanly during close: %s", exc)
|
|
839
943
|
self._run_task = None
|