openhands-agent-server 1.29.0__tar.gz → 1.29.2__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.29.0 → openhands_agent_server-1.29.2}/PKG-INFO +1 -1
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/agent_profiles_router.py +10 -9
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/api.py +22 -27
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/bash_router.py +20 -6
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/conversation_service.py +10 -6
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/dependencies.py +35 -32
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/docker/Dockerfile +13 -4
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/event_service.py +24 -3
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/file_router.py +30 -16
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/init_router.py +17 -0
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/openai/router.py +17 -18
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/openai/service.py +7 -4
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/persistence/__init__.py +4 -0
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/persistence/store.py +61 -0
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/profiles_router.py +10 -10
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/sockets.py +44 -6
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands_agent_server.egg-info/PKG-INFO +1 -1
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/pyproject.toml +1 -1
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/__init__.py +0 -0
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/__main__.py +0 -0
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/_secrets_exposure.py +0 -0
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/auth_router.py +0 -0
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/bash_service.py +0 -0
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/config.py +0 -0
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/conversation_lease.py +0 -0
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/conversation_router.py +0 -0
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/desktop_router.py +0 -0
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/desktop_service.py +0 -0
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/docker/build.py +0 -0
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/docker/wallpaper.svg +0 -0
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/env_parser.py +0 -0
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/event_router.py +0 -0
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/git_router.py +0 -0
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/hooks_router.py +0 -0
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/hooks_service.py +0 -0
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/llm_router.py +0 -0
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/logging_config.py +0 -0
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/mcp_router.py +0 -0
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/middleware.py +0 -0
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/models.py +0 -0
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/openai/__init__.py +0 -0
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/openai/models.py +0 -0
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/openapi.py +0 -0
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/persistence/models.py +0 -0
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/pub_sub.py +0 -0
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/py.typed +0 -0
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/server_details_router.py +0 -0
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/settings_router.py +0 -0
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/skills_router.py +0 -0
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/skills_service.py +0 -0
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/tool_preload_service.py +0 -0
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/tool_router.py +0 -0
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/utils.py +0 -0
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/vscode_extensions/openhands-settings/extension.js +0 -0
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/vscode_extensions/openhands-settings/package.json +0 -0
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/vscode_router.py +0 -0
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/vscode_service.py +0 -0
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/workspace_router.py +0 -0
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/workspaces_router.py +0 -0
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands_agent_server.egg-info/SOURCES.txt +0 -0
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands_agent_server.egg-info/dependency_links.txt +0 -0
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands_agent_server.egg-info/entry_points.txt +0 -0
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands_agent_server.egg-info/requires.txt +0 -0
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands_agent_server.egg-info/top_level.txt +0 -0
- {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openhands-agent-server
|
|
3
|
-
Version: 1.29.
|
|
3
|
+
Version: 1.29.2
|
|
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
|
|
@@ -30,9 +30,10 @@ from openhands.agent_server._secrets_exposure import (
|
|
|
30
30
|
)
|
|
31
31
|
from openhands.agent_server.persistence import (
|
|
32
32
|
PersistedSettings,
|
|
33
|
+
get_agent_profile_store,
|
|
34
|
+
get_llm_profile_store,
|
|
33
35
|
get_settings_store,
|
|
34
36
|
)
|
|
35
|
-
from openhands.sdk.llm.llm_profile_store import LLMProfileStore
|
|
36
37
|
from openhands.sdk.logger import get_logger
|
|
37
38
|
from openhands.sdk.profiles import (
|
|
38
39
|
ACPAgentProfile,
|
|
@@ -318,7 +319,7 @@ async def list_agent_profiles(request: Request) -> AgentProfileListResponse:
|
|
|
318
319
|
settings_store = get_settings_store(config)
|
|
319
320
|
settings = settings_store.load() or PersistedSettings()
|
|
320
321
|
|
|
321
|
-
store =
|
|
322
|
+
store = get_agent_profile_store()
|
|
322
323
|
with _store_errors():
|
|
323
324
|
existing = store.list()
|
|
324
325
|
|
|
@@ -347,7 +348,7 @@ async def get_agent_profile(
|
|
|
347
348
|
expose_mode = parse_expose_secrets_header(request)
|
|
348
349
|
cipher = get_cipher(request)
|
|
349
350
|
|
|
350
|
-
store =
|
|
351
|
+
store = get_agent_profile_store()
|
|
351
352
|
try:
|
|
352
353
|
with _store_errors():
|
|
353
354
|
profile = store.load(name, cipher=cipher)
|
|
@@ -408,7 +409,7 @@ async def save_agent_profile(
|
|
|
408
409
|
# secret once rather than double-encrypting the token.
|
|
409
410
|
profile = _decrypt_profile_mcp_tools(profile, cipher)
|
|
410
411
|
|
|
411
|
-
store =
|
|
412
|
+
store = get_agent_profile_store()
|
|
412
413
|
# The id is server-managed (the active pointer is keyed on it): overwrite
|
|
413
414
|
# keeps the namesake's id and bumps revision; create mints a fresh id,
|
|
414
415
|
# ignoring any client-supplied one. The lock spans read + mint + save so two
|
|
@@ -448,7 +449,7 @@ async def delete_agent_profile(
|
|
|
448
449
|
If the deleted profile was the active one, ``active_agent_profile_id`` is
|
|
449
450
|
cleared.
|
|
450
451
|
"""
|
|
451
|
-
store =
|
|
452
|
+
store = get_agent_profile_store()
|
|
452
453
|
deleted_id = _summary_id_for_name(store, name)
|
|
453
454
|
|
|
454
455
|
with _store_errors():
|
|
@@ -485,7 +486,7 @@ async def rename_agent_profile(
|
|
|
485
486
|
survives the rename untouched. Returns 404 if the source is missing, 409 if
|
|
486
487
|
``new_name`` is taken.
|
|
487
488
|
"""
|
|
488
|
-
store =
|
|
489
|
+
store = get_agent_profile_store()
|
|
489
490
|
try:
|
|
490
491
|
with _store_errors():
|
|
491
492
|
store.rename(name, body.new_name)
|
|
@@ -520,7 +521,7 @@ async def activate_agent_profile(
|
|
|
520
521
|
``/activate``, this does **not** write ``agent_settings`` (the
|
|
521
522
|
creation-time-only contract). Returns 404 if no stored profile has that id.
|
|
522
523
|
"""
|
|
523
|
-
store =
|
|
524
|
+
store = get_agent_profile_store()
|
|
524
525
|
with _store_errors():
|
|
525
526
|
known_ids = {
|
|
526
527
|
str(s["id"]) for s in store.list_summaries() if s.get("id") is not None
|
|
@@ -574,7 +575,7 @@ async def materialize_agent_profile(
|
|
|
574
575
|
"""
|
|
575
576
|
cipher = get_cipher(request)
|
|
576
577
|
|
|
577
|
-
store =
|
|
578
|
+
store = get_agent_profile_store()
|
|
578
579
|
try:
|
|
579
580
|
with _store_errors():
|
|
580
581
|
profile = store.load(name, cipher=cipher)
|
|
@@ -592,7 +593,7 @@ async def materialize_agent_profile(
|
|
|
592
593
|
settings = get_settings_store(config).load() or PersistedSettings()
|
|
593
594
|
mcp_config = settings.agent_settings.mcp_config
|
|
594
595
|
|
|
595
|
-
llm_store =
|
|
596
|
+
llm_store = get_llm_profile_store()
|
|
596
597
|
return resolve_agent_profile_dry_run(
|
|
597
598
|
profile,
|
|
598
599
|
llm_store=llm_store,
|
{openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/api.py
RENAMED
|
@@ -29,8 +29,8 @@ from openhands.agent_server.conversation_service import (
|
|
|
29
29
|
get_default_conversation_service,
|
|
30
30
|
)
|
|
31
31
|
from openhands.agent_server.dependencies import (
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
check_session_api_key,
|
|
33
|
+
check_workspace_session,
|
|
34
34
|
)
|
|
35
35
|
from openhands.agent_server.desktop_router import desktop_router
|
|
36
36
|
from openhands.agent_server.desktop_service import get_desktop_service
|
|
@@ -47,7 +47,7 @@ from openhands.agent_server.llm_router import llm_router
|
|
|
47
47
|
from openhands.agent_server.mcp_router import mcp_router
|
|
48
48
|
from openhands.agent_server.middleware import CORSDispatcher
|
|
49
49
|
from openhands.agent_server.openai.router import (
|
|
50
|
-
|
|
50
|
+
check_openai_api_key,
|
|
51
51
|
openai_router,
|
|
52
52
|
)
|
|
53
53
|
from openhands.agent_server.profiles_router import profiles_router
|
|
@@ -234,6 +234,9 @@ async def api_lifespan(api: FastAPI) -> AsyncIterator[None]:
|
|
|
234
234
|
mark_initialization_complete()
|
|
235
235
|
logger.info("Server initialization complete - ready to serve requests")
|
|
236
236
|
|
|
237
|
+
bash_svc = get_default_bash_event_service()
|
|
238
|
+
api.state.bash_event_service = bash_svc
|
|
239
|
+
|
|
237
240
|
async with service:
|
|
238
241
|
api.state.conversation_service = service
|
|
239
242
|
|
|
@@ -241,7 +244,7 @@ async def api_lifespan(api: FastAPI) -> AsyncIterator[None]:
|
|
|
241
244
|
retention_task: asyncio.Task | None = None
|
|
242
245
|
if config.bash_events_retention_seconds is not None:
|
|
243
246
|
retention_task = asyncio.create_task(
|
|
244
|
-
|
|
247
|
+
bash_svc.run_retention_cleanup_loop(
|
|
245
248
|
config.bash_events_retention_seconds
|
|
246
249
|
)
|
|
247
250
|
)
|
|
@@ -310,12 +313,8 @@ def _find_http_exception(exc: BaseExceptionGroup) -> HTTPException | None:
|
|
|
310
313
|
return None
|
|
311
314
|
|
|
312
315
|
|
|
313
|
-
def _add_api_routes(app: FastAPI
|
|
314
|
-
"""Add all API routes to the FastAPI application.
|
|
315
|
-
|
|
316
|
-
Args:
|
|
317
|
-
app: FastAPI application instance to add routes to.
|
|
318
|
-
"""
|
|
316
|
+
def _add_api_routes(app: FastAPI) -> None:
|
|
317
|
+
"""Add all API routes to the FastAPI application."""
|
|
319
318
|
app.include_router(server_details_router)
|
|
320
319
|
|
|
321
320
|
# The /api/init endpoint bypasses both the session-key auth and the
|
|
@@ -330,12 +329,14 @@ def _add_api_routes(app: FastAPI, config: Config) -> None:
|
|
|
330
329
|
# Header-only auth: applied to every /api/* route EXCEPT the workspace
|
|
331
330
|
# static-file routes (handled separately below). Cookies are NOT honored
|
|
332
331
|
# here so that we don't expand the CSRF surface across the whole API.
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
332
|
+
# check_session_api_key reads config from request.app.state at request time,
|
|
333
|
+
# so keys delivered via POST /api/init are honoured without re-registering routes.
|
|
334
|
+
dependencies = [
|
|
335
|
+
Depends(check_session_api_key),
|
|
336
|
+
# Dormant gate: 503s every /api/* route until POST /api/init completes.
|
|
337
|
+
# No-op for non-deferred deployments.
|
|
338
|
+
Depends(require_initialized),
|
|
339
|
+
]
|
|
339
340
|
|
|
340
341
|
api_router = APIRouter(prefix="/api", dependencies=dependencies)
|
|
341
342
|
api_router.include_router(event_router)
|
|
@@ -359,22 +360,16 @@ def _add_api_routes(app: FastAPI, config: Config) -> None:
|
|
|
359
360
|
api_router.include_router(auth_router)
|
|
360
361
|
app.include_router(api_router)
|
|
361
362
|
|
|
362
|
-
|
|
363
|
-
if config.session_api_keys:
|
|
364
|
-
openai_dependencies.append(Depends(create_openai_api_key_dependency(config)))
|
|
365
|
-
app.include_router(openai_router, dependencies=openai_dependencies)
|
|
363
|
+
app.include_router(openai_router, dependencies=[Depends(check_openai_api_key)])
|
|
366
364
|
|
|
367
365
|
# Workspace static-file routes get their own auth group that accepts
|
|
368
366
|
# EITHER the X-Session-API-Key header OR the workspace session cookie.
|
|
369
367
|
# The cookie is required so that <iframe src> / <img src> embeds of
|
|
370
368
|
# workspace artifacts work — browsers cannot attach custom headers to
|
|
371
369
|
# those requests.
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
Depends(create_workspace_session_dependency(config))
|
|
376
|
-
)
|
|
377
|
-
workspace_api_router = APIRouter(prefix="/api", dependencies=workspace_dependencies)
|
|
370
|
+
workspace_api_router = APIRouter(
|
|
371
|
+
prefix="/api", dependencies=[Depends(check_workspace_session)]
|
|
372
|
+
)
|
|
378
373
|
workspace_api_router.include_router(workspace_router)
|
|
379
374
|
app.include_router(workspace_api_router)
|
|
380
375
|
|
|
@@ -592,7 +587,7 @@ def create_app(config: Config | None = None) -> FastAPI:
|
|
|
592
587
|
app = _create_fastapi_instance(config)
|
|
593
588
|
app.state.config = config
|
|
594
589
|
|
|
595
|
-
_add_api_routes(app
|
|
590
|
+
_add_api_routes(app)
|
|
596
591
|
_setup_static_files(app, config)
|
|
597
592
|
app.add_middleware(
|
|
598
593
|
CORSDispatcher,
|
|
@@ -7,12 +7,14 @@ from uuid import UUID
|
|
|
7
7
|
|
|
8
8
|
from fastapi import (
|
|
9
9
|
APIRouter,
|
|
10
|
+
Depends,
|
|
10
11
|
HTTPException,
|
|
11
12
|
Query,
|
|
12
13
|
status,
|
|
13
14
|
)
|
|
14
15
|
|
|
15
|
-
from openhands.agent_server.bash_service import
|
|
16
|
+
from openhands.agent_server.bash_service import BashEventService
|
|
17
|
+
from openhands.agent_server.dependencies import get_bash_event_service
|
|
16
18
|
from openhands.agent_server.models import (
|
|
17
19
|
BashCommand,
|
|
18
20
|
BashEventBase,
|
|
@@ -25,7 +27,6 @@ from openhands.agent_server.server_details_router import update_last_execution_t
|
|
|
25
27
|
|
|
26
28
|
|
|
27
29
|
bash_router = APIRouter(prefix="/bash", tags=["Bash"])
|
|
28
|
-
bash_event_service = get_default_bash_event_service()
|
|
29
30
|
logger = logging.getLogger(__name__)
|
|
30
31
|
|
|
31
32
|
|
|
@@ -53,6 +54,7 @@ async def search_bash_events(
|
|
|
53
54
|
int,
|
|
54
55
|
Query(title="The max number of results in the page", gt=0, lte=100),
|
|
55
56
|
] = 100,
|
|
57
|
+
bash_event_service: BashEventService = Depends(get_bash_event_service),
|
|
56
58
|
) -> BashEventPage:
|
|
57
59
|
"""Search / List bash event events"""
|
|
58
60
|
assert limit > 0
|
|
@@ -73,7 +75,10 @@ async def search_bash_events(
|
|
|
73
75
|
@bash_router.get(
|
|
74
76
|
"/bash_events/{event_id}", responses={404: {"description": "Item not found"}}
|
|
75
77
|
)
|
|
76
|
-
async def get_bash_event(
|
|
78
|
+
async def get_bash_event(
|
|
79
|
+
event_id: str,
|
|
80
|
+
bash_event_service: BashEventService = Depends(get_bash_event_service),
|
|
81
|
+
) -> BashEventBase:
|
|
77
82
|
"""Get a bash event event given an id"""
|
|
78
83
|
event = await bash_event_service.get_bash_event(event_id)
|
|
79
84
|
if event is None:
|
|
@@ -84,6 +89,7 @@ async def get_bash_event(event_id: str) -> BashEventBase:
|
|
|
84
89
|
@bash_router.get("/bash_events/")
|
|
85
90
|
async def batch_get_bash_events(
|
|
86
91
|
event_ids: list[str],
|
|
92
|
+
bash_event_service: BashEventService = Depends(get_bash_event_service),
|
|
87
93
|
) -> list[BashEventBase | None]:
|
|
88
94
|
"""Get a batch of bash event events given their ids, returning null for any
|
|
89
95
|
missing item."""
|
|
@@ -92,7 +98,10 @@ async def batch_get_bash_events(
|
|
|
92
98
|
|
|
93
99
|
|
|
94
100
|
@bash_router.post("/start_bash_command")
|
|
95
|
-
async def start_bash_command(
|
|
101
|
+
async def start_bash_command(
|
|
102
|
+
request: ExecuteBashRequest,
|
|
103
|
+
bash_event_service: BashEventService = Depends(get_bash_event_service),
|
|
104
|
+
) -> BashCommand:
|
|
96
105
|
"""Execute a bash command in the background"""
|
|
97
106
|
update_last_execution_time()
|
|
98
107
|
command, _ = await bash_event_service.start_bash_command(request)
|
|
@@ -100,7 +109,10 @@ async def start_bash_command(request: ExecuteBashRequest) -> BashCommand:
|
|
|
100
109
|
|
|
101
110
|
|
|
102
111
|
@bash_router.post("/execute_bash_command")
|
|
103
|
-
async def execute_bash_command(
|
|
112
|
+
async def execute_bash_command(
|
|
113
|
+
request: ExecuteBashRequest,
|
|
114
|
+
bash_event_service: BashEventService = Depends(get_bash_event_service),
|
|
115
|
+
) -> BashOutput:
|
|
104
116
|
"""Execute a bash command and wait for a result"""
|
|
105
117
|
update_last_execution_time()
|
|
106
118
|
command, task = await bash_event_service.start_bash_command(request)
|
|
@@ -111,7 +123,9 @@ async def execute_bash_command(request: ExecuteBashRequest) -> BashOutput:
|
|
|
111
123
|
|
|
112
124
|
|
|
113
125
|
@bash_router.delete("/bash_events")
|
|
114
|
-
async def clear_all_bash_events(
|
|
126
|
+
async def clear_all_bash_events(
|
|
127
|
+
bash_event_service: BashEventService = Depends(get_bash_event_service),
|
|
128
|
+
) -> dict[str, int]:
|
|
115
129
|
"""Clear all bash events from storage"""
|
|
116
130
|
count = await bash_event_service.clear_all_events()
|
|
117
131
|
return {"cleared_count": count}
|
|
@@ -261,11 +261,13 @@ def _resolve_agent_from_profile(
|
|
|
261
261
|
DanglingMcpServerRef: A referenced MCP server is absent from the global config.
|
|
262
262
|
ValueError: Profile load or settings validation failure.
|
|
263
263
|
"""
|
|
264
|
-
from openhands.
|
|
265
|
-
|
|
264
|
+
from openhands.agent_server.persistence.store import (
|
|
265
|
+
get_agent_profile_store,
|
|
266
|
+
get_llm_profile_store,
|
|
267
|
+
)
|
|
266
268
|
from openhands.sdk.profiles.resolver import ProfileNotFound, resolve_agent_profile
|
|
267
269
|
|
|
268
|
-
store =
|
|
270
|
+
store = get_agent_profile_store()
|
|
269
271
|
profile_name = store.name_for_id(profile_id)
|
|
270
272
|
if profile_name is None:
|
|
271
273
|
raise ProfileNotFound(f"Agent profile with id '{profile_id}' not found")
|
|
@@ -281,7 +283,7 @@ def _resolve_agent_from_profile(
|
|
|
281
283
|
f"Failed to load agent profile '{profile_name}': {exc}"
|
|
282
284
|
) from exc
|
|
283
285
|
|
|
284
|
-
llm_store =
|
|
286
|
+
llm_store = get_llm_profile_store()
|
|
285
287
|
try:
|
|
286
288
|
settings_config = resolve_agent_profile(
|
|
287
289
|
profile, llm_store=llm_store, mcp_config=mcp_config, cipher=cipher
|
|
@@ -1343,9 +1345,11 @@ class AutoTitleSubscriber(Subscriber):
|
|
|
1343
1345
|
return None
|
|
1344
1346
|
|
|
1345
1347
|
try:
|
|
1346
|
-
from openhands.
|
|
1348
|
+
from openhands.agent_server.persistence.store import (
|
|
1349
|
+
get_llm_profile_store,
|
|
1350
|
+
)
|
|
1347
1351
|
|
|
1348
|
-
profile_store =
|
|
1352
|
+
profile_store = get_llm_profile_store()
|
|
1349
1353
|
return profile_store.load(profile_name, cipher=self.service.cipher)
|
|
1350
1354
|
except (FileNotFoundError, ValueError) as e:
|
|
1351
1355
|
logger.warning(
|
|
@@ -3,6 +3,7 @@ from uuid import UUID
|
|
|
3
3
|
from fastapi import Depends, HTTPException, Request, status
|
|
4
4
|
from fastapi.security import APIKeyCookie, APIKeyHeader
|
|
5
5
|
|
|
6
|
+
from openhands.agent_server.bash_service import BashEventService
|
|
6
7
|
from openhands.agent_server.config import Config
|
|
7
8
|
from openhands.agent_server.conversation_service import ConversationService
|
|
8
9
|
from openhands.agent_server.event_service import EventService
|
|
@@ -20,22 +21,26 @@ _WORKSPACE_SESSION_COOKIE = APIKeyCookie(
|
|
|
20
21
|
)
|
|
21
22
|
|
|
22
23
|
|
|
23
|
-
def
|
|
24
|
-
|
|
24
|
+
def check_session_api_key(
|
|
25
|
+
request: Request,
|
|
26
|
+
session_api_key: str | None = Depends(_SESSION_API_KEY_HEADER),
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Reject the request if the supplied key is not in the current session keys.
|
|
25
29
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
raise HTTPException(status.HTTP_401_UNAUTHORIZED)
|
|
34
|
-
|
|
35
|
-
return check_session_api_key
|
|
30
|
+
Reads ``session_api_keys`` from ``request.app.state.config`` at request time
|
|
31
|
+
so that keys delivered via ``POST /api/init`` take effect immediately without
|
|
32
|
+
restarting the server or re-registering routes.
|
|
33
|
+
"""
|
|
34
|
+
config: Config = request.app.state.config
|
|
35
|
+
if config.session_api_keys and session_api_key not in config.session_api_keys:
|
|
36
|
+
raise HTTPException(status.HTTP_401_UNAUTHORIZED)
|
|
36
37
|
|
|
37
38
|
|
|
38
|
-
def
|
|
39
|
+
def check_workspace_session(
|
|
40
|
+
request: Request,
|
|
41
|
+
header_key: str | None = Depends(_SESSION_API_KEY_HEADER),
|
|
42
|
+
cookie_key: str | None = Depends(_WORKSPACE_SESSION_COOKIE),
|
|
43
|
+
) -> None:
|
|
39
44
|
"""Auth dependency for the workspace static-file routes.
|
|
40
45
|
|
|
41
46
|
Accepts EITHER the standard ``X-Session-API-Key`` header OR the
|
|
@@ -46,28 +51,16 @@ def create_workspace_session_dependency(config: Config):
|
|
|
46
51
|
frontend embeds workspace artifacts. The cookie is deliberately scoped
|
|
47
52
|
to this router only; no other endpoint honors it.
|
|
48
53
|
"""
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
if not config.session_api_keys:
|
|
54
|
+
config: Config = request.app.state.config
|
|
55
|
+
if not config.session_api_keys:
|
|
56
|
+
return
|
|
57
|
+
for candidate in (header_key, cookie_key):
|
|
58
|
+
if candidate and candidate in config.session_api_keys:
|
|
55
59
|
return
|
|
56
|
-
|
|
57
|
-
if candidate and candidate in config.session_api_keys:
|
|
58
|
-
return
|
|
59
|
-
raise HTTPException(status.HTTP_401_UNAUTHORIZED)
|
|
60
|
-
|
|
61
|
-
return check_workspace_session
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def get_conversation_service(request: Request):
|
|
65
|
-
"""Get the conversation service from app state.
|
|
60
|
+
raise HTTPException(status.HTTP_401_UNAUTHORIZED)
|
|
66
61
|
|
|
67
|
-
This dependency ensures that the conversation service is properly initialized
|
|
68
|
-
through the application lifespan context manager.
|
|
69
|
-
"""
|
|
70
62
|
|
|
63
|
+
def get_conversation_service(request: Request) -> ConversationService:
|
|
71
64
|
service = getattr(request.app.state, "conversation_service", None)
|
|
72
65
|
if service is None:
|
|
73
66
|
raise HTTPException(
|
|
@@ -77,6 +70,16 @@ def get_conversation_service(request: Request):
|
|
|
77
70
|
return service
|
|
78
71
|
|
|
79
72
|
|
|
73
|
+
def get_bash_event_service(request: Request) -> BashEventService:
|
|
74
|
+
service = getattr(request.app.state, "bash_event_service", None)
|
|
75
|
+
if service is None:
|
|
76
|
+
raise HTTPException(
|
|
77
|
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
78
|
+
detail="Bash event service is not available",
|
|
79
|
+
)
|
|
80
|
+
return service
|
|
81
|
+
|
|
82
|
+
|
|
80
83
|
async def get_event_service(
|
|
81
84
|
conversation_id: UUID,
|
|
82
85
|
conversation_service: ConversationService = Depends(get_conversation_service),
|
|
@@ -8,6 +8,11 @@ ARG USERNAME=openhands
|
|
|
8
8
|
ARG UID=10001
|
|
9
9
|
ARG GID=10001
|
|
10
10
|
ARG PORT=8000
|
|
11
|
+
# Opt-in build flag for the Vertex AI extra (`openhands-sdk[vertex]`). Off by
|
|
12
|
+
# default to keep the published image lean. Pass `--build-arg ENABLE_VERTEX=1`
|
|
13
|
+
# to bundle google-cloud-aiplatform so the resulting binary supports
|
|
14
|
+
# `vertex_ai/*` partner models (MiniMax, Qwen, Kimi MaaS endpoints).
|
|
15
|
+
ARG ENABLE_VERTEX=0
|
|
11
16
|
|
|
12
17
|
####################################################################################
|
|
13
18
|
# Builder (source mode)
|
|
@@ -26,7 +31,7 @@ ARG PORT=8000
|
|
|
26
31
|
# See OpenHands/software-agent-sdk#2761.
|
|
27
32
|
####################################################################################
|
|
28
33
|
FROM python:3.13-bookworm AS builder
|
|
29
|
-
ARG USERNAME UID GID
|
|
34
|
+
ARG USERNAME UID GID ENABLE_VERTEX
|
|
30
35
|
ENV UV_PROJECT_ENVIRONMENT=/agent-server/.venv
|
|
31
36
|
ENV UV_PYTHON_INSTALL_DIR=/agent-server/uv-managed-python
|
|
32
37
|
|
|
@@ -48,9 +53,11 @@ COPY --chown=${USERNAME}:${USERNAME} openhands-tools ./openhands-tools
|
|
|
48
53
|
COPY --chown=${USERNAME}:${USERNAME} openhands-workspace ./openhands-workspace
|
|
49
54
|
COPY --chown=${USERNAME}:${USERNAME} openhands-agent-server ./openhands-agent-server
|
|
50
55
|
RUN --mount=type=cache,target=/home/${USERNAME}/.cache,uid=${UID},gid=${GID} \
|
|
56
|
+
EXTRA_FLAGS=""; \
|
|
57
|
+
if [ "$ENABLE_VERTEX" = "1" ]; then EXTRA_FLAGS="--extra vertex"; fi; \
|
|
51
58
|
uv python install 3.13 && \
|
|
52
59
|
uv venv --python-preference only-managed --python 3.13 .venv && \
|
|
53
|
-
uv sync --frozen --no-editable --managed-python --extra boto3 && \
|
|
60
|
+
uv sync --frozen --no-editable --managed-python --extra boto3 $EXTRA_FLAGS && \
|
|
54
61
|
readlink -f .venv/bin/python | grep -q '^/agent-server/uv-managed-python/'
|
|
55
62
|
|
|
56
63
|
####################################################################################
|
|
@@ -58,11 +65,13 @@ RUN --mount=type=cache,target=/home/${USERNAME}/.cache,uid=${UID},gid=${GID} \
|
|
|
58
65
|
# We run pyinstaller here to produce openhands-agent-server
|
|
59
66
|
####################################################################################
|
|
60
67
|
FROM builder AS binary-builder
|
|
61
|
-
ARG USERNAME UID GID
|
|
68
|
+
ARG USERNAME UID GID ENABLE_VERTEX
|
|
62
69
|
|
|
63
70
|
# We need --dev for pyinstaller
|
|
64
71
|
RUN --mount=type=cache,target=/home/${USERNAME}/.cache,uid=${UID},gid=${GID} \
|
|
65
|
-
|
|
72
|
+
EXTRA_FLAGS=""; \
|
|
73
|
+
if [ "$ENABLE_VERTEX" = "1" ]; then EXTRA_FLAGS="--extra vertex"; fi; \
|
|
74
|
+
uv sync --frozen --dev --no-editable --extra boto3 $EXTRA_FLAGS
|
|
66
75
|
|
|
67
76
|
RUN --mount=type=cache,target=/home/${USERNAME}/.cache,uid=${UID},gid=${GID} \
|
|
68
77
|
uv run pyinstaller openhands-agent-server/openhands/agent_server/agent-server.spec
|
|
@@ -6,6 +6,8 @@ from datetime import datetime
|
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
from uuid import UUID, uuid4
|
|
8
8
|
|
|
9
|
+
from pydantic import ValidationError
|
|
10
|
+
|
|
9
11
|
from openhands.agent_server.conversation_lease import (
|
|
10
12
|
ConversationLease,
|
|
11
13
|
ConversationOwnershipLostError,
|
|
@@ -20,6 +22,7 @@ from openhands.agent_server.pub_sub import PubSub, Subscriber
|
|
|
20
22
|
from openhands.sdk import LLM, AgentBase, Event, Message, TextContent, get_logger
|
|
21
23
|
from openhands.sdk.agent import ACPAgent
|
|
22
24
|
from openhands.sdk.conversation.base import BaseConversation
|
|
25
|
+
from openhands.sdk.conversation.events_list_base import EventsListBase
|
|
23
26
|
from openhands.sdk.conversation.goal import (
|
|
24
27
|
GoalController,
|
|
25
28
|
GoalDone,
|
|
@@ -217,6 +220,18 @@ class EventService:
|
|
|
217
220
|
return False
|
|
218
221
|
return True
|
|
219
222
|
|
|
223
|
+
def _get_searchable_event(self, events: EventsListBase, index: int) -> Event | None:
|
|
224
|
+
try:
|
|
225
|
+
return events[index]
|
|
226
|
+
except (FileNotFoundError, UnicodeDecodeError, ValidationError) as exc:
|
|
227
|
+
logger.warning(
|
|
228
|
+
"Skipping unreadable event at index %d for conversation %s (%s)",
|
|
229
|
+
index,
|
|
230
|
+
self.stored.id,
|
|
231
|
+
type(exc).__name__,
|
|
232
|
+
)
|
|
233
|
+
return None
|
|
234
|
+
|
|
220
235
|
def _search_events_sync(
|
|
221
236
|
self,
|
|
222
237
|
page_id: str | None = None,
|
|
@@ -272,7 +287,8 @@ class EventService:
|
|
|
272
287
|
start_index = None
|
|
273
288
|
else:
|
|
274
289
|
for i in range(total):
|
|
275
|
-
|
|
290
|
+
event = self._get_searchable_event(events, i)
|
|
291
|
+
if event is not None and event.id == page_id:
|
|
276
292
|
start_index = i
|
|
277
293
|
break
|
|
278
294
|
if start_index is None:
|
|
@@ -286,7 +302,9 @@ class EventService:
|
|
|
286
302
|
items: list[Event] = []
|
|
287
303
|
next_page_id: str | None = None
|
|
288
304
|
for i in indices:
|
|
289
|
-
event = events
|
|
305
|
+
event = self._get_searchable_event(events, i)
|
|
306
|
+
if event is None:
|
|
307
|
+
continue
|
|
290
308
|
if not self._event_matches_filters(
|
|
291
309
|
event, kind, source, body, timestamp_gte_str, timestamp_lt_str
|
|
292
310
|
):
|
|
@@ -360,7 +378,10 @@ class EventService:
|
|
|
360
378
|
timestamp_lt_str = timestamp__lt.isoformat() if timestamp__lt else None
|
|
361
379
|
|
|
362
380
|
count = 0
|
|
363
|
-
for
|
|
381
|
+
for i in range(len(events)):
|
|
382
|
+
event = self._get_searchable_event(events, i)
|
|
383
|
+
if event is None:
|
|
384
|
+
continue
|
|
364
385
|
if self._event_matches_filters(
|
|
365
386
|
event, kind, source, body, timestamp_gte_str, timestamp_lt_str
|
|
366
387
|
):
|
|
@@ -151,17 +151,20 @@ async def download_file_query(
|
|
|
151
151
|
return await _download_file(path)
|
|
152
152
|
|
|
153
153
|
|
|
154
|
-
def _list_home_favorites(
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
154
|
+
def _list_home_favorites(
|
|
155
|
+
home: Path, limit: int = 50, include_hidden: bool = False
|
|
156
|
+
) -> list[FileBrowserEntry]:
|
|
157
|
+
"""Top-level directories inside the user's home, alphabetised.
|
|
158
|
+
|
|
159
|
+
Symlinks are skipped. Hidden entries (names starting with '.') are skipped
|
|
160
|
+
unless ``include_hidden`` is True, so the list matches what
|
|
161
|
+
``search_subdirs`` returns for the same path and the same flag.
|
|
159
162
|
"""
|
|
160
163
|
entries: list[FileBrowserEntry] = []
|
|
161
164
|
try:
|
|
162
165
|
with os.scandir(home) as scanner:
|
|
163
166
|
for entry in scanner:
|
|
164
|
-
if entry.name.startswith("."):
|
|
167
|
+
if not include_hidden and entry.name.startswith("."):
|
|
165
168
|
continue
|
|
166
169
|
try:
|
|
167
170
|
if not entry.is_dir(follow_symlinks=False):
|
|
@@ -197,18 +200,24 @@ def _list_root_locations() -> list[FileBrowserEntry]:
|
|
|
197
200
|
|
|
198
201
|
|
|
199
202
|
@file_router.get("/home")
|
|
200
|
-
async def get_home_directory(
|
|
203
|
+
async def get_home_directory(
|
|
204
|
+
include_hidden: Annotated[
|
|
205
|
+
bool,
|
|
206
|
+
Query(description="Include hidden top-level directories in `favorites`"),
|
|
207
|
+
] = False,
|
|
208
|
+
) -> HomeResponse:
|
|
201
209
|
"""Return the agent-server user's home directory and dynamic sidebar lists.
|
|
202
210
|
|
|
203
|
-
``favorites`` is the set of
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
211
|
+
``favorites`` is the set of top-level directories actually present in the
|
|
212
|
+
user's home (so it reflects the real environment instead of a hardcoded
|
|
213
|
+
list of names that may not exist). Hidden directories are included only
|
|
214
|
+
when ``include_hidden`` is True. ``locations`` is the set of filesystem
|
|
215
|
+
roots — '/' on POSIX or available drive letters on Windows.
|
|
207
216
|
"""
|
|
208
217
|
home = Path.home()
|
|
209
218
|
return HomeResponse(
|
|
210
219
|
home=str(home),
|
|
211
|
-
favorites=_list_home_favorites(home),
|
|
220
|
+
favorites=_list_home_favorites(home, include_hidden=include_hidden),
|
|
212
221
|
locations=_list_root_locations(),
|
|
213
222
|
)
|
|
214
223
|
|
|
@@ -227,12 +236,17 @@ async def search_subdirs(
|
|
|
227
236
|
int,
|
|
228
237
|
Query(title="The max number of results in the page", gt=0, lte=100),
|
|
229
238
|
] = 100,
|
|
239
|
+
include_hidden: Annotated[
|
|
240
|
+
bool,
|
|
241
|
+
Query(title="Include hidden subdirectories (names starting with '.')"),
|
|
242
|
+
] = False,
|
|
230
243
|
) -> SubdirectoryPage:
|
|
231
244
|
"""Search / List immediate subdirectories of `path`.
|
|
232
245
|
|
|
233
|
-
Used by the GUI's workspace picker.
|
|
234
|
-
|
|
235
|
-
GUI can use a result directly as
|
|
246
|
+
Used by the GUI's workspace picker. Symlinks and files are skipped. Hidden
|
|
247
|
+
entries (names starting with '.') are skipped unless ``include_hidden`` is
|
|
248
|
+
True. Returns absolute paths so the GUI can use a result directly as
|
|
249
|
+
``workspace.working_dir``.
|
|
236
250
|
|
|
237
251
|
Results are sorted case-insensitively by name and paginated. ``page_id`` is
|
|
238
252
|
the ``next_page_id`` returned by the previous page (the lowercase name of
|
|
@@ -262,7 +276,7 @@ async def search_subdirs(
|
|
|
262
276
|
try:
|
|
263
277
|
with os.scandir(target) as scanner:
|
|
264
278
|
for entry in scanner:
|
|
265
|
-
if entry.name.startswith("."):
|
|
279
|
+
if not include_hidden and entry.name.startswith("."):
|
|
266
280
|
continue
|
|
267
281
|
try:
|
|
268
282
|
if not entry.is_dir(follow_symlinks=False):
|
|
@@ -21,6 +21,7 @@ from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request, status
|
|
|
21
21
|
from fastapi.security import APIKeyHeader
|
|
22
22
|
from pydantic import BaseModel, ConfigDict, Field, SecretStr
|
|
23
23
|
|
|
24
|
+
from openhands.agent_server.bash_service import BashEventService
|
|
24
25
|
from openhands.agent_server.config import Config, WebhookSpec
|
|
25
26
|
from openhands.agent_server.conversation_service import ConversationService
|
|
26
27
|
from openhands.agent_server.server_details_router import mark_initialization_complete
|
|
@@ -175,6 +176,7 @@ class InitService:
|
|
|
175
176
|
self._error: str | None = None
|
|
176
177
|
self._lock = asyncio.Lock()
|
|
177
178
|
self._entered_service: ConversationService | None = None
|
|
179
|
+
self._entered_bash_service: BashEventService | None = None
|
|
178
180
|
|
|
179
181
|
@property
|
|
180
182
|
def state(self) -> InitState:
|
|
@@ -209,10 +211,22 @@ class InitService:
|
|
|
209
211
|
service = ConversationService.get_instance(new_config)
|
|
210
212
|
cs_mod._conversation_service = service
|
|
211
213
|
|
|
214
|
+
bash_svc = BashEventService(bash_events_dir=new_config.bash_events_dir)
|
|
215
|
+
await bash_svc.__aenter__()
|
|
216
|
+
self._entered_bash_service = bash_svc
|
|
217
|
+
|
|
212
218
|
await service.__aenter__()
|
|
213
219
|
self._entered_service = service
|
|
214
220
|
self._app.state.config = new_config
|
|
215
221
|
self._app.state.conversation_service = service
|
|
222
|
+
self._app.state.bash_event_service = bash_svc
|
|
223
|
+
|
|
224
|
+
# Re-derive root_path from the merged config so Doc URLS are valid
|
|
225
|
+
from openhands.agent_server.api import _get_root_path
|
|
226
|
+
|
|
227
|
+
new_root_path = _get_root_path(new_config)
|
|
228
|
+
self._app.root_path = new_root_path
|
|
229
|
+
|
|
216
230
|
mark_initialization_complete()
|
|
217
231
|
self._state = "ready"
|
|
218
232
|
logger.info("deferred_init: server transitioned to ready")
|
|
@@ -235,6 +249,9 @@ class InitService:
|
|
|
235
249
|
if self._entered_service is not None:
|
|
236
250
|
await self._entered_service.__aexit__(None, None, None)
|
|
237
251
|
self._entered_service = None
|
|
252
|
+
if self._entered_bash_service is not None:
|
|
253
|
+
await self._entered_bash_service.__aexit__(None, None, None)
|
|
254
|
+
self._entered_bash_service = None
|
|
238
255
|
|
|
239
256
|
|
|
240
257
|
def get_init_service(request: Request) -> InitService:
|
|
@@ -28,7 +28,11 @@ _SESSION_API_KEY_HEADER = APIKeyHeader(name="X-Session-API-Key", auto_error=Fals
|
|
|
28
28
|
_AUTHORIZATION_HEADER = HTTPBearer(auto_error=False)
|
|
29
29
|
|
|
30
30
|
|
|
31
|
-
def
|
|
31
|
+
def check_openai_api_key(
|
|
32
|
+
request: Request,
|
|
33
|
+
session_api_key: str | None = Depends(_SESSION_API_KEY_HEADER),
|
|
34
|
+
authorization: HTTPAuthorizationCredentials | None = Depends(_AUTHORIZATION_HEADER),
|
|
35
|
+
) -> None:
|
|
32
36
|
"""Accept the same session key through OpenHands and OpenAI auth shapes.
|
|
33
37
|
|
|
34
38
|
``X-Session-API-Key`` preserves compatibility with existing agent-server
|
|
@@ -37,24 +41,19 @@ def create_openai_api_key_dependency(config: Config):
|
|
|
37
41
|
``config.session_api_keys``; this does not introduce a second credential
|
|
38
42
|
system. When no session keys are configured, the local server remains
|
|
39
43
|
unauthenticated like the existing agent-server API.
|
|
40
|
-
"""
|
|
41
44
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
return
|
|
55
|
-
raise HTTPException(status.HTTP_401_UNAUTHORIZED)
|
|
56
|
-
|
|
57
|
-
return check_openai_api_key
|
|
45
|
+
Reads config from ``request.app.state`` at request time so that keys
|
|
46
|
+
delivered via ``POST /api/init`` take effect immediately.
|
|
47
|
+
"""
|
|
48
|
+
config: Config = request.app.state.config
|
|
49
|
+
if not config.session_api_keys:
|
|
50
|
+
return
|
|
51
|
+
bearer_token = authorization.credentials if authorization else None
|
|
52
|
+
if session_api_key in config.session_api_keys:
|
|
53
|
+
return
|
|
54
|
+
if bearer_token in config.session_api_keys:
|
|
55
|
+
return
|
|
56
|
+
raise HTTPException(status.HTTP_401_UNAUTHORIZED)
|
|
58
57
|
|
|
59
58
|
|
|
60
59
|
def _get_config(request: Request) -> Config:
|
|
@@ -25,7 +25,11 @@ from openhands.agent_server.openai.models import (
|
|
|
25
25
|
OpenAIResponseMessage,
|
|
26
26
|
OpenAIUsage,
|
|
27
27
|
)
|
|
28
|
-
from openhands.agent_server.persistence import
|
|
28
|
+
from openhands.agent_server.persistence import (
|
|
29
|
+
PersistedSettings,
|
|
30
|
+
get_llm_profile_store,
|
|
31
|
+
get_settings_store,
|
|
32
|
+
)
|
|
29
33
|
from openhands.sdk import LLM, Message
|
|
30
34
|
from openhands.sdk.context.agent_context import AgentContext
|
|
31
35
|
from openhands.sdk.conversation.request import (
|
|
@@ -36,7 +40,6 @@ from openhands.sdk.conversation.state import (
|
|
|
36
40
|
ConversationExecutionStatus,
|
|
37
41
|
ConversationState,
|
|
38
42
|
)
|
|
39
|
-
from openhands.sdk.llm.llm_profile_store import LLMProfileStore
|
|
40
43
|
from openhands.sdk.llm.message import ImageContent, TextContent
|
|
41
44
|
from openhands.sdk.settings import ACPAgentSettings, OpenHandsAgentSettings
|
|
42
45
|
from openhands.sdk.workspace import LocalWorkspace
|
|
@@ -66,7 +69,7 @@ def _profile_name_from_model(model: str) -> str:
|
|
|
66
69
|
|
|
67
70
|
def _load_profile_llm(profile_name: str, config: Config) -> LLM:
|
|
68
71
|
try:
|
|
69
|
-
return
|
|
72
|
+
return get_llm_profile_store().load(profile_name, cipher=config.cipher)
|
|
70
73
|
except FileNotFoundError:
|
|
71
74
|
raise HTTPException(
|
|
72
75
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
@@ -369,7 +372,7 @@ def iter_openai_chat_completion_sse(
|
|
|
369
372
|
|
|
370
373
|
async def list_openai_models() -> OpenAIModelListResponse:
|
|
371
374
|
try:
|
|
372
|
-
profiles =
|
|
375
|
+
profiles = get_llm_profile_store().list_summaries()
|
|
373
376
|
except TimeoutError:
|
|
374
377
|
raise HTTPException(
|
|
375
378
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
@@ -25,6 +25,8 @@ from openhands.agent_server.persistence.store import (
|
|
|
25
25
|
SecretsStore,
|
|
26
26
|
SettingsStore,
|
|
27
27
|
WorkspacesStore,
|
|
28
|
+
get_agent_profile_store,
|
|
29
|
+
get_llm_profile_store,
|
|
28
30
|
get_secrets_store,
|
|
29
31
|
get_settings_store,
|
|
30
32
|
get_workspaces_store,
|
|
@@ -52,6 +54,8 @@ __all__ = [
|
|
|
52
54
|
"SecretsStore",
|
|
53
55
|
"SettingsStore",
|
|
54
56
|
"WorkspacesStore",
|
|
57
|
+
"get_agent_profile_store",
|
|
58
|
+
"get_llm_profile_store",
|
|
55
59
|
"get_secrets_store",
|
|
56
60
|
"get_settings_store",
|
|
57
61
|
"get_workspaces_store",
|
|
@@ -27,7 +27,9 @@ from openhands.agent_server.persistence.models import (
|
|
|
27
27
|
PersistedWorkspaces,
|
|
28
28
|
Secrets,
|
|
29
29
|
)
|
|
30
|
+
from openhands.sdk.llm.llm_profile_store import LLMProfileStore
|
|
30
31
|
from openhands.sdk.logger import get_logger
|
|
32
|
+
from openhands.sdk.profiles.agent_profile_store import AgentProfileStore
|
|
31
33
|
from openhands.sdk.utils.cipher import Cipher
|
|
32
34
|
|
|
33
35
|
|
|
@@ -687,6 +689,8 @@ class FileWorkspacesStore(WorkspacesStore):
|
|
|
687
689
|
_settings_store: FileSettingsStore | None = None
|
|
688
690
|
_secrets_store: FileSecretsStore | None = None
|
|
689
691
|
_workspaces_store: FileWorkspacesStore | None = None
|
|
692
|
+
_llm_profile_store: LLMProfileStore | None = None
|
|
693
|
+
_agent_profile_store: AgentProfileStore | None = None
|
|
690
694
|
_store_lock = threading.Lock()
|
|
691
695
|
|
|
692
696
|
|
|
@@ -704,6 +708,20 @@ def _get_persistence_dir(config: Config | None = None) -> Path:
|
|
|
704
708
|
return DEFAULT_PERSISTENCE_DIR
|
|
705
709
|
|
|
706
710
|
|
|
711
|
+
def _get_profile_persistence_dir() -> Path:
|
|
712
|
+
"""Get the base dir for LLM/agent profile stores.
|
|
713
|
+
|
|
714
|
+
Profiles can hold credentials (LLM API keys), so absent
|
|
715
|
+
``OH_PERSISTENCE_DIR`` they fall back to the user's ``~/.openhands``
|
|
716
|
+
rather than the workspace-relative agent-server default, keeping bare
|
|
717
|
+
local profile secrets in the expected user config directory.
|
|
718
|
+
"""
|
|
719
|
+
env_dir = os.environ.get("OH_PERSISTENCE_DIR")
|
|
720
|
+
if env_dir:
|
|
721
|
+
return Path(env_dir)
|
|
722
|
+
return Path.home() / ".openhands"
|
|
723
|
+
|
|
724
|
+
|
|
707
725
|
def _get_cipher(config: Config | None = None) -> Cipher | None:
|
|
708
726
|
"""Get cipher from config for encrypting secrets."""
|
|
709
727
|
if config is not None:
|
|
@@ -794,10 +812,53 @@ def get_workspaces_store(config: Config | None = None) -> FileWorkspacesStore:
|
|
|
794
812
|
return _workspaces_store
|
|
795
813
|
|
|
796
814
|
|
|
815
|
+
def get_llm_profile_store() -> LLMProfileStore:
|
|
816
|
+
"""Get the global ``LLMProfileStore`` instance (thread-safe).
|
|
817
|
+
|
|
818
|
+
Stored at ``<dir>/profiles`` where ``<dir>`` is ``OH_PERSISTENCE_DIR``
|
|
819
|
+
when set, else ``~/.openhands``. This honors isolated agent-server
|
|
820
|
+
instances while keeping bare local profile secrets in the user's
|
|
821
|
+
config directory (never workspace-relative). See
|
|
822
|
+
:func:`_get_profile_persistence_dir`.
|
|
823
|
+
"""
|
|
824
|
+
global _llm_profile_store
|
|
825
|
+
if _llm_profile_store is not None:
|
|
826
|
+
return _llm_profile_store
|
|
827
|
+
|
|
828
|
+
with _store_lock:
|
|
829
|
+
if _llm_profile_store is None:
|
|
830
|
+
_llm_profile_store = LLMProfileStore(
|
|
831
|
+
base_dir=_get_profile_persistence_dir() / "profiles",
|
|
832
|
+
)
|
|
833
|
+
return _llm_profile_store
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def get_agent_profile_store() -> AgentProfileStore:
|
|
837
|
+
"""Get the global ``AgentProfileStore`` instance (thread-safe).
|
|
838
|
+
|
|
839
|
+
Stored at ``<dir>/agent-profiles`` where ``<dir>`` is resolved by
|
|
840
|
+
:func:`_get_profile_persistence_dir`, for the same reason as
|
|
841
|
+
:func:`get_llm_profile_store`.
|
|
842
|
+
"""
|
|
843
|
+
global _agent_profile_store
|
|
844
|
+
if _agent_profile_store is not None:
|
|
845
|
+
return _agent_profile_store
|
|
846
|
+
|
|
847
|
+
with _store_lock:
|
|
848
|
+
if _agent_profile_store is None:
|
|
849
|
+
_agent_profile_store = AgentProfileStore(
|
|
850
|
+
base_dir=_get_profile_persistence_dir() / "agent-profiles",
|
|
851
|
+
)
|
|
852
|
+
return _agent_profile_store
|
|
853
|
+
|
|
854
|
+
|
|
797
855
|
def reset_stores() -> None:
|
|
798
856
|
"""Reset global store instances (for testing)."""
|
|
799
857
|
global _settings_store, _secrets_store, _workspaces_store
|
|
858
|
+
global _llm_profile_store, _agent_profile_store
|
|
800
859
|
with _store_lock:
|
|
801
860
|
_settings_store = None
|
|
802
861
|
_secrets_store = None
|
|
803
862
|
_workspaces_store = None
|
|
863
|
+
_llm_profile_store = None
|
|
864
|
+
_agent_profile_store = None
|
|
@@ -17,17 +17,17 @@ from openhands.agent_server._secrets_exposure import (
|
|
|
17
17
|
)
|
|
18
18
|
from openhands.agent_server.persistence import (
|
|
19
19
|
PersistedSettings,
|
|
20
|
+
get_agent_profile_store,
|
|
21
|
+
get_llm_profile_store,
|
|
20
22
|
get_settings_store,
|
|
21
23
|
)
|
|
22
24
|
from openhands.sdk.llm import LLM
|
|
23
25
|
from openhands.sdk.llm.llm_profile_store import (
|
|
24
26
|
PROFILE_NAME_PATTERN,
|
|
25
|
-
LLMProfileStore,
|
|
26
27
|
ProfileLimitExceeded,
|
|
27
28
|
)
|
|
28
29
|
from openhands.sdk.logger import get_logger
|
|
29
30
|
from openhands.sdk.profiles import (
|
|
30
|
-
AgentProfileStore,
|
|
31
31
|
ProfileReferenced,
|
|
32
32
|
delete_llm_profile,
|
|
33
33
|
rename_llm_profile,
|
|
@@ -140,7 +140,7 @@ async def list_profiles(request: Request) -> ProfileListResponse:
|
|
|
140
140
|
settings_store = get_settings_store(config)
|
|
141
141
|
settings = settings_store.load() or PersistedSettings()
|
|
142
142
|
|
|
143
|
-
store =
|
|
143
|
+
store = get_llm_profile_store()
|
|
144
144
|
with _store_errors():
|
|
145
145
|
summaries = store.list_summaries()
|
|
146
146
|
|
|
@@ -162,7 +162,7 @@ async def get_profile(request: Request, name: ProfileName) -> ProfileDetailRespo
|
|
|
162
162
|
expose_mode = parse_expose_secrets_header(request)
|
|
163
163
|
cipher = get_cipher(request)
|
|
164
164
|
|
|
165
|
-
store =
|
|
165
|
+
store = get_llm_profile_store()
|
|
166
166
|
try:
|
|
167
167
|
with _store_errors():
|
|
168
168
|
llm = store.load(name, cipher=cipher)
|
|
@@ -206,7 +206,7 @@ async def save_profile(
|
|
|
206
206
|
"""
|
|
207
207
|
cipher = get_cipher(request)
|
|
208
208
|
llm = decrypt_incoming_llm_secrets(body.llm, cipher) if cipher else body.llm
|
|
209
|
-
store =
|
|
209
|
+
store = get_llm_profile_store()
|
|
210
210
|
try:
|
|
211
211
|
with _store_errors():
|
|
212
212
|
store.save(
|
|
@@ -238,8 +238,8 @@ async def delete_profile(
|
|
|
238
238
|
Guarded by the agent-profile FK: returns 409 naming the referrers if any
|
|
239
239
|
``AgentProfile`` still cites this LLM profile via ``llm_profile_ref``.
|
|
240
240
|
"""
|
|
241
|
-
store =
|
|
242
|
-
agent_store =
|
|
241
|
+
store = get_llm_profile_store()
|
|
242
|
+
agent_store = get_agent_profile_store()
|
|
243
243
|
try:
|
|
244
244
|
with _store_errors():
|
|
245
245
|
delete_llm_profile(agent_store, store, name)
|
|
@@ -266,8 +266,8 @@ async def rename_profile(
|
|
|
266
266
|
setting is updated to the new name. Any ``AgentProfile.llm_profile_ref``
|
|
267
267
|
citing the old name is cascaded to the new name in lock-step.
|
|
268
268
|
"""
|
|
269
|
-
store =
|
|
270
|
-
agent_store =
|
|
269
|
+
store = get_llm_profile_store()
|
|
270
|
+
agent_store = get_agent_profile_store()
|
|
271
271
|
try:
|
|
272
272
|
with _store_errors():
|
|
273
273
|
rename_llm_profile(agent_store, store, name, body.new_name)
|
|
@@ -323,7 +323,7 @@ async def activate_profile(
|
|
|
323
323
|
config = get_config(request)
|
|
324
324
|
|
|
325
325
|
# Load the profile
|
|
326
|
-
profile_store =
|
|
326
|
+
profile_store = get_llm_profile_store()
|
|
327
327
|
try:
|
|
328
328
|
with _store_errors():
|
|
329
329
|
llm = profile_store.load(name, cipher=cipher)
|
{openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/sockets.py
RENAMED
|
@@ -28,9 +28,13 @@ from fastapi import (
|
|
|
28
28
|
)
|
|
29
29
|
from starlette.websockets import WebSocketState
|
|
30
30
|
|
|
31
|
-
from openhands.agent_server.bash_service import
|
|
31
|
+
from openhands.agent_server.bash_service import (
|
|
32
|
+
BashEventService,
|
|
33
|
+
get_default_bash_event_service,
|
|
34
|
+
)
|
|
32
35
|
from openhands.agent_server.config import Config, get_default_config
|
|
33
36
|
from openhands.agent_server.conversation_service import (
|
|
37
|
+
ConversationService,
|
|
34
38
|
get_default_conversation_service,
|
|
35
39
|
)
|
|
36
40
|
from openhands.agent_server.event_router import normalize_datetime_to_server_timezone
|
|
@@ -64,6 +68,38 @@ def _get_config(websocket: WebSocket) -> Config:
|
|
|
64
68
|
return get_default_config()
|
|
65
69
|
|
|
66
70
|
|
|
71
|
+
def _get_conversation_service(websocket: WebSocket) -> ConversationService:
|
|
72
|
+
"""Return the ConversationService for this FastAPI app instance.
|
|
73
|
+
|
|
74
|
+
Looks up ``app.state.conversation_service`` at request time so that the
|
|
75
|
+
service delivered via ``POST /api/init`` (deferred-init / dormant mode)
|
|
76
|
+
is used instead of the module-level default captured at import. When
|
|
77
|
+
``app.state`` is not configured (e.g. when sockets.py is imported as a
|
|
78
|
+
library without a lifespan), falls back to the module-level singleton,
|
|
79
|
+
which keeps the behaviour of existing tests that patch the module-level
|
|
80
|
+
variable.
|
|
81
|
+
"""
|
|
82
|
+
service = getattr(websocket.app.state, "conversation_service", None)
|
|
83
|
+
if isinstance(service, ConversationService):
|
|
84
|
+
return service
|
|
85
|
+
return conversation_service
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _get_bash_event_service(websocket: WebSocket) -> BashEventService:
|
|
89
|
+
"""Return the BashEventService for this FastAPI app instance.
|
|
90
|
+
|
|
91
|
+
Looks up ``app.state.bash_event_service`` at request time so that the
|
|
92
|
+
service delivered via ``POST /api/init`` (deferred-init / dormant mode)
|
|
93
|
+
is used instead of the module-level default captured at import. When
|
|
94
|
+
``app.state`` is not configured (e.g. when sockets.py is imported as a
|
|
95
|
+
library without a lifespan), falls back to the module-level singleton.
|
|
96
|
+
"""
|
|
97
|
+
service = getattr(websocket.app.state, "bash_event_service", None)
|
|
98
|
+
if isinstance(service, BashEventService):
|
|
99
|
+
return service
|
|
100
|
+
return bash_event_service
|
|
101
|
+
|
|
102
|
+
|
|
67
103
|
def _resolve_websocket_session_api_key(
|
|
68
104
|
websocket: WebSocket,
|
|
69
105
|
session_api_key: str | None,
|
|
@@ -242,7 +278,8 @@ async def events_socket(
|
|
|
242
278
|
return
|
|
243
279
|
|
|
244
280
|
logger.info(f"Event Websocket Connected: {conversation_id}")
|
|
245
|
-
|
|
281
|
+
conv_service = _get_conversation_service(websocket)
|
|
282
|
+
event_service = await conv_service.get_event_service(conversation_id)
|
|
246
283
|
if event_service is None:
|
|
247
284
|
logger.warning(f"Converation not found: {conversation_id}")
|
|
248
285
|
await websocket.close(code=4004, reason="Conversation not found")
|
|
@@ -372,9 +409,10 @@ async def bash_events_socket(
|
|
|
372
409
|
if not await _accept_authenticated_websocket(websocket, session_api_key):
|
|
373
410
|
return
|
|
374
411
|
|
|
412
|
+
bash_service = _get_bash_event_service(websocket)
|
|
375
413
|
logger.info("Bash Websocket Connected")
|
|
376
414
|
try:
|
|
377
|
-
subscriber_id = await
|
|
415
|
+
subscriber_id = await bash_service.subscribe_to_events(
|
|
378
416
|
_BashWebSocketSubscriber(websocket)
|
|
379
417
|
)
|
|
380
418
|
except MaxSubscribersError:
|
|
@@ -392,7 +430,7 @@ async def bash_events_socket(
|
|
|
392
430
|
# Resend all existing events if requested
|
|
393
431
|
if effective_mode == "all":
|
|
394
432
|
logger.info("Resending bash events")
|
|
395
|
-
async for event in page_iterator(
|
|
433
|
+
async for event in page_iterator(bash_service.search_bash_events):
|
|
396
434
|
await _send_bash_event(event, websocket)
|
|
397
435
|
|
|
398
436
|
while True:
|
|
@@ -401,7 +439,7 @@ async def bash_events_socket(
|
|
|
401
439
|
data = await websocket.receive_json()
|
|
402
440
|
logger.info("Received bash request")
|
|
403
441
|
request = ExecuteBashRequest.model_validate(data)
|
|
404
|
-
await
|
|
442
|
+
await bash_service.start_bash_command(request)
|
|
405
443
|
except WebSocketDisconnect:
|
|
406
444
|
logger.info("Bash websocket disconnected")
|
|
407
445
|
return
|
|
@@ -428,7 +466,7 @@ async def bash_events_socket(
|
|
|
428
466
|
await _safe_close_websocket(websocket)
|
|
429
467
|
return
|
|
430
468
|
finally:
|
|
431
|
-
await
|
|
469
|
+
await bash_service.unsubscribe_from_events(subscriber_id)
|
|
432
470
|
|
|
433
471
|
|
|
434
472
|
async def _send_event(event: Event, websocket: WebSocket):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openhands-agent-server
|
|
3
|
-
Version: 1.29.
|
|
3
|
+
Version: 1.29.2
|
|
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.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/__init__.py
RENAMED
|
File without changes
|
{openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/__main__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/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
|
{openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/env_parser.py
RENAMED
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/git_router.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/llm_router.py
RENAMED
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/mcp_router.py
RENAMED
|
File without changes
|
{openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/middleware.py
RENAMED
|
File without changes
|
{openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/models.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/openapi.py
RENAMED
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/pub_sub.py
RENAMED
|
File without changes
|
{openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/py.typed
RENAMED
|
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.29.0 → openhands_agent_server-1.29.2}/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
|