openhands-agent-server 1.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. openhands_agent_server-1.3.0/PKG-INFO +14 -0
  2. openhands_agent_server-1.3.0/openhands/agent_server/__init__.py +0 -0
  3. openhands_agent_server-1.3.0/openhands/agent_server/__main__.py +54 -0
  4. openhands_agent_server-1.3.0/openhands/agent_server/api.py +292 -0
  5. openhands_agent_server-1.3.0/openhands/agent_server/bash_router.py +105 -0
  6. openhands_agent_server-1.3.0/openhands/agent_server/bash_service.py +379 -0
  7. openhands_agent_server-1.3.0/openhands/agent_server/config.py +165 -0
  8. openhands_agent_server-1.3.0/openhands/agent_server/conversation_router.py +309 -0
  9. openhands_agent_server-1.3.0/openhands/agent_server/conversation_service.py +603 -0
  10. openhands_agent_server-1.3.0/openhands/agent_server/dependencies.py +72 -0
  11. openhands_agent_server-1.3.0/openhands/agent_server/desktop_router.py +47 -0
  12. openhands_agent_server-1.3.0/openhands/agent_server/desktop_service.py +212 -0
  13. openhands_agent_server-1.3.0/openhands/agent_server/docker/Dockerfile +227 -0
  14. openhands_agent_server-1.3.0/openhands/agent_server/docker/build.py +803 -0
  15. openhands_agent_server-1.3.0/openhands/agent_server/docker/wallpaper.svg +22 -0
  16. openhands_agent_server-1.3.0/openhands/agent_server/env_parser.py +430 -0
  17. openhands_agent_server-1.3.0/openhands/agent_server/event_router.py +204 -0
  18. openhands_agent_server-1.3.0/openhands/agent_server/event_service.py +503 -0
  19. openhands_agent_server-1.3.0/openhands/agent_server/file_router.py +121 -0
  20. openhands_agent_server-1.3.0/openhands/agent_server/git_router.py +34 -0
  21. openhands_agent_server-1.3.0/openhands/agent_server/logging_config.py +98 -0
  22. openhands_agent_server-1.3.0/openhands/agent_server/middleware.py +32 -0
  23. openhands_agent_server-1.3.0/openhands/agent_server/models.py +262 -0
  24. openhands_agent_server-1.3.0/openhands/agent_server/openapi.py +21 -0
  25. openhands_agent_server-1.3.0/openhands/agent_server/pub_sub.py +80 -0
  26. openhands_agent_server-1.3.0/openhands/agent_server/py.typed +0 -0
  27. openhands_agent_server-1.3.0/openhands/agent_server/server_details_router.py +43 -0
  28. openhands_agent_server-1.3.0/openhands/agent_server/sockets.py +184 -0
  29. openhands_agent_server-1.3.0/openhands/agent_server/tool_router.py +20 -0
  30. openhands_agent_server-1.3.0/openhands/agent_server/utils.py +63 -0
  31. openhands_agent_server-1.3.0/openhands/agent_server/vscode_extensions/openhands-settings/extension.js +22 -0
  32. openhands_agent_server-1.3.0/openhands/agent_server/vscode_extensions/openhands-settings/package.json +12 -0
  33. openhands_agent_server-1.3.0/openhands/agent_server/vscode_router.py +70 -0
  34. openhands_agent_server-1.3.0/openhands/agent_server/vscode_service.py +232 -0
  35. openhands_agent_server-1.3.0/openhands_agent_server.egg-info/PKG-INFO +14 -0
  36. openhands_agent_server-1.3.0/openhands_agent_server.egg-info/SOURCES.txt +73 -0
  37. openhands_agent_server-1.3.0/openhands_agent_server.egg-info/dependency_links.txt +1 -0
  38. openhands_agent_server-1.3.0/openhands_agent_server.egg-info/entry_points.txt +2 -0
  39. openhands_agent_server-1.3.0/openhands_agent_server.egg-info/requires.txt +9 -0
  40. openhands_agent_server-1.3.0/openhands_agent_server.egg-info/top_level.txt +1 -0
  41. openhands_agent_server-1.3.0/pyproject.toml +42 -0
  42. openhands_agent_server-1.3.0/setup.cfg +4 -0
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.4
2
+ Name: openhands-agent-server
3
+ Version: 1.3.0
4
+ Summary: OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: aiosqlite>=0.19
7
+ Requires-Dist: alembic>=1.13
8
+ Requires-Dist: docker<8,>=7.1
9
+ Requires-Dist: fastapi>=0.104
10
+ Requires-Dist: pydantic>=2
11
+ Requires-Dist: sqlalchemy>=2
12
+ Requires-Dist: uvicorn>=0.31.1
13
+ Requires-Dist: websockets>=12
14
+ Requires-Dist: wsproto>=1.2.0
@@ -0,0 +1,54 @@
1
+ import argparse
2
+
3
+ import uvicorn
4
+
5
+ from openhands.agent_server.logging_config import LOGGING_CONFIG
6
+ from openhands.sdk.logger import DEBUG
7
+
8
+
9
+ def main():
10
+ parser = argparse.ArgumentParser(description="OpenHands Agent Server App")
11
+ parser.add_argument(
12
+ "--host", default="0.0.0.0", help="Host to bind to (default: 0.0.0.0)"
13
+ )
14
+ parser.add_argument(
15
+ "--port", type=int, default=8000, help="Port to bind to (default: 8000)"
16
+ )
17
+ parser.add_argument(
18
+ "--reload",
19
+ dest="reload",
20
+ default=False,
21
+ action="store_true",
22
+ help="Enable auto-reload (disabled by default)",
23
+ )
24
+
25
+ args = parser.parse_args()
26
+
27
+ print(f"🙌 Starting OpenHands Agent Server on {args.host}:{args.port}")
28
+ print(f"📖 API docs will be available at http://{args.host}:{args.port}/docs")
29
+ print(f"🔄 Auto-reload: {'enabled' if args.reload else 'disabled'}")
30
+
31
+ # Show debug mode status
32
+ if DEBUG:
33
+ print("🐛 DEBUG mode: ENABLED (stack traces will be shown)")
34
+ else:
35
+ print("🔒 DEBUG mode: DISABLED")
36
+ print()
37
+
38
+ # Configure uvicorn logging based on DEBUG environment variable
39
+ log_level = "debug" if DEBUG else "info"
40
+
41
+ uvicorn.run(
42
+ "openhands.agent_server.api:api",
43
+ host=args.host,
44
+ port=args.port,
45
+ reload=args.reload,
46
+ reload_includes=["openhands-agent-server", "openhands-sdk", "openhands-tools"],
47
+ log_level=log_level,
48
+ log_config=LOGGING_CONFIG,
49
+ ws="wsproto", # Use wsproto instead of deprecated websockets implementation
50
+ )
51
+
52
+
53
+ if __name__ == "__main__":
54
+ main()
@@ -0,0 +1,292 @@
1
+ import traceback
2
+ from collections.abc import AsyncIterator
3
+ from contextlib import asynccontextmanager
4
+
5
+ from fastapi import APIRouter, Depends, FastAPI, HTTPException
6
+ from fastapi.responses import JSONResponse, RedirectResponse
7
+ from fastapi.staticfiles import StaticFiles
8
+ from starlette.requests import Request
9
+
10
+ from openhands.agent_server.bash_router import bash_router
11
+ from openhands.agent_server.config import (
12
+ Config,
13
+ get_default_config,
14
+ )
15
+ from openhands.agent_server.conversation_router import conversation_router
16
+ from openhands.agent_server.conversation_service import (
17
+ get_default_conversation_service,
18
+ )
19
+ from openhands.agent_server.dependencies import create_session_api_key_dependency
20
+ from openhands.agent_server.desktop_router import desktop_router
21
+ from openhands.agent_server.desktop_service import get_desktop_service
22
+ from openhands.agent_server.event_router import event_router
23
+ from openhands.agent_server.file_router import file_router
24
+ from openhands.agent_server.git_router import git_router
25
+ from openhands.agent_server.middleware import LocalhostCORSMiddleware
26
+ from openhands.agent_server.server_details_router import (
27
+ get_server_info,
28
+ server_details_router,
29
+ )
30
+ from openhands.agent_server.sockets import sockets_router
31
+ from openhands.agent_server.tool_router import tool_router
32
+ from openhands.agent_server.vscode_router import vscode_router
33
+ from openhands.agent_server.vscode_service import get_vscode_service
34
+ from openhands.sdk.logger import DEBUG, get_logger
35
+
36
+
37
+ logger = get_logger(__name__)
38
+
39
+
40
+ @asynccontextmanager
41
+ async def api_lifespan(api: FastAPI) -> AsyncIterator[None]:
42
+ service = get_default_conversation_service()
43
+ vscode_service = get_vscode_service()
44
+ desktop_service = get_desktop_service()
45
+
46
+ # Start VSCode service if enabled
47
+ if vscode_service is not None:
48
+ vscode_started = await vscode_service.start()
49
+ if vscode_started:
50
+ logger.info("VSCode service started successfully")
51
+ else:
52
+ logger.warning("VSCode service failed to start, continuing without VSCode")
53
+ else:
54
+ logger.info("VSCode service is disabled")
55
+
56
+ # Start Desktop service if enabled
57
+ if desktop_service is not None:
58
+ desktop_started = await desktop_service.start()
59
+ if desktop_started:
60
+ logger.info("Desktop service started successfully")
61
+ else:
62
+ logger.warning(
63
+ "Desktop service failed to start, continuing without desktop"
64
+ )
65
+ else:
66
+ logger.info("Desktop service is disabled")
67
+
68
+ async with service:
69
+ # Store the initialized service in app state for dependency injection
70
+ api.state.conversation_service = service
71
+ try:
72
+ yield
73
+ finally:
74
+ # Stop services on shutdown
75
+ if vscode_service is not None:
76
+ await vscode_service.stop()
77
+ if desktop_service is not None:
78
+ await desktop_service.stop()
79
+
80
+
81
+ def _create_fastapi_instance() -> FastAPI:
82
+ """Create the basic FastAPI application instance.
83
+
84
+ Returns:
85
+ Basic FastAPI application with title, description, and lifespan.
86
+ """
87
+ return FastAPI(
88
+ title="OpenHands Agent Server",
89
+ description=(
90
+ "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
91
+ ),
92
+ lifespan=api_lifespan,
93
+ )
94
+
95
+
96
+ def _find_http_exception(exc: BaseExceptionGroup) -> HTTPException | None:
97
+ """Helper function to find HTTPException in ExceptionGroup.
98
+
99
+ Args:
100
+ exc: BaseExceptionGroup to search for HTTPException.
101
+
102
+ Returns:
103
+ HTTPException if found, None otherwise.
104
+ """
105
+ for inner_exc in exc.exceptions:
106
+ if isinstance(inner_exc, HTTPException):
107
+ return inner_exc
108
+ # Recursively search nested ExceptionGroups
109
+ if isinstance(inner_exc, BaseExceptionGroup):
110
+ found = _find_http_exception(inner_exc)
111
+ if found:
112
+ return found
113
+ return None
114
+
115
+
116
+ def _add_api_routes(app: FastAPI, config: Config) -> None:
117
+ """Add all API routes to the FastAPI application.
118
+
119
+ Args:
120
+ app: FastAPI application instance to add routes to.
121
+ """
122
+ app.include_router(server_details_router)
123
+
124
+ dependencies = []
125
+ if config.session_api_keys:
126
+ dependencies.append(Depends(create_session_api_key_dependency(config)))
127
+
128
+ api_router = APIRouter(prefix="/api", dependencies=dependencies)
129
+ api_router.include_router(event_router)
130
+ api_router.include_router(conversation_router)
131
+ api_router.include_router(tool_router)
132
+ api_router.include_router(bash_router)
133
+ api_router.include_router(git_router)
134
+ api_router.include_router(file_router)
135
+ api_router.include_router(vscode_router)
136
+ api_router.include_router(desktop_router)
137
+ app.include_router(api_router)
138
+ app.include_router(sockets_router)
139
+
140
+
141
+ def _setup_static_files(app: FastAPI, config: Config) -> None:
142
+ """Set up static file serving and root redirect if configured.
143
+
144
+ Args:
145
+ app: FastAPI application instance.
146
+ config: Configuration object containing static files settings.
147
+ """
148
+ # Only proceed if static files are configured and directory exists
149
+ if not (
150
+ config.static_files_path
151
+ and config.static_files_path.exists()
152
+ and config.static_files_path.is_dir()
153
+ ):
154
+ # Map the root path to server info if there are no static files
155
+ app.get("/")(get_server_info)
156
+ return
157
+
158
+ # Mount static files directory
159
+ app.mount(
160
+ "/static",
161
+ StaticFiles(directory=str(config.static_files_path)),
162
+ name="static",
163
+ )
164
+
165
+ # Add root redirect to static files
166
+ @app.get("/", tags=["Server Details"])
167
+ async def root_redirect():
168
+ """Redirect root endpoint to static files directory."""
169
+ # Check if index.html exists in the static directory
170
+ # We know static_files_path is not None here due to the outer condition
171
+ assert config.static_files_path is not None
172
+ index_path = config.static_files_path / "index.html"
173
+ if index_path.exists():
174
+ return RedirectResponse(url="/static/index.html", status_code=302)
175
+ else:
176
+ return RedirectResponse(url="/static/", status_code=302)
177
+
178
+
179
+ def _add_exception_handlers(api: FastAPI) -> None:
180
+ """Add exception handlers to the FastAPI application."""
181
+
182
+ @api.exception_handler(Exception)
183
+ async def _unhandled_exception_handler(
184
+ request: Request, exc: Exception
185
+ ) -> JSONResponse:
186
+ """Handle unhandled exceptions."""
187
+ # Always log that we're in the exception handler for debugging
188
+ logger.debug(
189
+ "Exception handler called for %s %s with %s: %s",
190
+ request.method,
191
+ request.url.path,
192
+ type(exc).__name__,
193
+ str(exc),
194
+ )
195
+
196
+ content = {
197
+ "detail": "Internal Server Error",
198
+ "exception": str(exc),
199
+ }
200
+ # In DEBUG mode, include stack trace in response
201
+ if DEBUG:
202
+ content["traceback"] = traceback.format_exc()
203
+ # Check if this is an HTTPException that should be handled directly
204
+ if isinstance(exc, HTTPException):
205
+ return await _http_exception_handler(request, exc)
206
+
207
+ # Check if this is a BaseExceptionGroup with HTTPExceptions
208
+ if isinstance(exc, BaseExceptionGroup):
209
+ http_exc = _find_http_exception(exc)
210
+ if http_exc:
211
+ return await _http_exception_handler(request, http_exc)
212
+ # If no HTTPException found, treat as unhandled exception
213
+ logger.error(
214
+ "Unhandled ExceptionGroup on %s %s",
215
+ request.method,
216
+ request.url.path,
217
+ exc_info=(type(exc), exc, exc.__traceback__),
218
+ )
219
+ return JSONResponse(status_code=500, content=content)
220
+
221
+ # Logs full stack trace for any unhandled error that FastAPI would
222
+ # turn into a 500
223
+ logger.error(
224
+ "Unhandled exception on %s %s",
225
+ request.method,
226
+ request.url.path,
227
+ exc_info=(type(exc), exc, exc.__traceback__),
228
+ )
229
+ return JSONResponse(status_code=500, content=content)
230
+
231
+ @api.exception_handler(HTTPException)
232
+ async def _http_exception_handler(
233
+ request: Request, exc: HTTPException
234
+ ) -> JSONResponse:
235
+ """Handle HTTPExceptions with appropriate logging."""
236
+ # Log 4xx errors at info level (expected client errors like auth failures)
237
+ if 400 <= exc.status_code < 500:
238
+ logger.info(
239
+ "HTTPException %d on %s %s: %s",
240
+ exc.status_code,
241
+ request.method,
242
+ request.url.path,
243
+ exc.detail,
244
+ )
245
+ # Log 5xx errors at error level with full traceback (server errors)
246
+ elif exc.status_code >= 500:
247
+ logger.error(
248
+ "HTTPException %d on %s %s: %s",
249
+ exc.status_code,
250
+ request.method,
251
+ request.url.path,
252
+ exc.detail,
253
+ exc_info=(type(exc), exc, exc.__traceback__),
254
+ )
255
+ content = {
256
+ "detail": "Internal Server Error",
257
+ "exception": str(exc),
258
+ }
259
+ if DEBUG:
260
+ content["traceback"] = traceback.format_exc()
261
+ # Don't leak internal details to clients for 5xx errors in production
262
+ return JSONResponse(
263
+ status_code=exc.status_code,
264
+ content=content,
265
+ )
266
+
267
+ # Return clean JSON response for all non-5xx HTTP exceptions
268
+ return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
269
+
270
+
271
+ def create_app(config: Config | None = None) -> FastAPI:
272
+ """Create and configure the FastAPI application.
273
+
274
+ Args:
275
+ config: Configuration object. If None, uses default config.
276
+
277
+ Returns:
278
+ Configured FastAPI application.
279
+ """
280
+ if config is None:
281
+ config = get_default_config()
282
+ app = _create_fastapi_instance()
283
+ _add_api_routes(app, config)
284
+ _setup_static_files(app, config)
285
+ app.add_middleware(LocalhostCORSMiddleware, allow_origins=config.allow_cors_origins)
286
+ _add_exception_handlers(app)
287
+
288
+ return app
289
+
290
+
291
+ # Create the default app instance
292
+ api = create_app()
@@ -0,0 +1,105 @@
1
+ """Bash router for OpenHands SDK."""
2
+
3
+ import logging
4
+ from datetime import datetime
5
+ from typing import Annotated, Literal, cast
6
+ from uuid import UUID
7
+
8
+ from fastapi import (
9
+ APIRouter,
10
+ HTTPException,
11
+ Query,
12
+ status,
13
+ )
14
+
15
+ from openhands.agent_server.bash_service import get_default_bash_event_service
16
+ from openhands.agent_server.models import (
17
+ BashCommand,
18
+ BashEventBase,
19
+ BashEventPage,
20
+ BashEventSortOrder,
21
+ BashOutput,
22
+ ExecuteBashRequest,
23
+ )
24
+
25
+
26
+ bash_router = APIRouter(prefix="/bash", tags=["Bash"])
27
+ bash_event_service = get_default_bash_event_service()
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ # bash event routes
32
+ @bash_router.get("/bash_events/search")
33
+ async def search_bash_events(
34
+ kind__eq: Literal["BashCommand", "BashOutput"] | None = None,
35
+ command_id__eq: UUID | None = None,
36
+ timestamp__gte: datetime | None = None,
37
+ timestamp__lt: datetime | None = None,
38
+ sort_order: BashEventSortOrder = BashEventSortOrder.TIMESTAMP,
39
+ page_id: Annotated[
40
+ str | None,
41
+ Query(title="Optional next_page_id from the previously returned page"),
42
+ ] = None,
43
+ limit: Annotated[
44
+ int,
45
+ Query(title="The max number of results in the page", gt=0, lte=100),
46
+ ] = 100,
47
+ ) -> BashEventPage:
48
+ """Search / List bash event events"""
49
+ assert limit > 0
50
+ assert limit <= 100
51
+
52
+ return await bash_event_service.search_bash_events(
53
+ kind__eq=kind__eq,
54
+ command_id__eq=command_id__eq,
55
+ timestamp__gte=timestamp__gte,
56
+ timestamp__lt=timestamp__lt,
57
+ sort_order=sort_order,
58
+ page_id=page_id,
59
+ limit=limit,
60
+ )
61
+
62
+
63
+ @bash_router.get(
64
+ "/bash_events/{event_id}", responses={404: {"description": "Item not found"}}
65
+ )
66
+ async def get_bash_event(event_id: str) -> BashEventBase:
67
+ """Get a bash event event given an id"""
68
+ event = await bash_event_service.get_bash_event(event_id)
69
+ if event is None:
70
+ raise HTTPException(status.HTTP_404_NOT_FOUND)
71
+ return event
72
+
73
+
74
+ @bash_router.get("/bash_events/")
75
+ async def batch_get_bash_events(
76
+ event_ids: list[str],
77
+ ) -> list[BashEventBase | None]:
78
+ """Get a batch of bash event events given their ids, returning null for any
79
+ missing item."""
80
+ events = await bash_event_service.batch_get_bash_events(event_ids)
81
+ return events
82
+
83
+
84
+ @bash_router.post("/start_bash_command")
85
+ async def start_bash_command(request: ExecuteBashRequest) -> BashCommand:
86
+ """Execute a bash command in the background"""
87
+ command, _ = await bash_event_service.start_bash_command(request)
88
+ return command
89
+
90
+
91
+ @bash_router.post("/execute_bash_command")
92
+ async def execute_bash_command(request: ExecuteBashRequest) -> BashOutput:
93
+ """Execute a bash command and wait for a result"""
94
+ command, task = await bash_event_service.start_bash_command(request)
95
+ await task
96
+ page = await bash_event_service.search_bash_events(command_id__eq=command.id)
97
+ result = cast(BashOutput, page.items[-1])
98
+ return result
99
+
100
+
101
+ @bash_router.delete("/bash_events")
102
+ async def clear_all_bash_events() -> dict[str, int]:
103
+ """Clear all bash events from storage"""
104
+ count = await bash_event_service.clear_all_events()
105
+ return {"cleared_count": count}