openhands-agent-server 1.0.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.0.0/PKG-INFO +14 -0
  2. openhands_agent_server-1.0.0/openhands/agent_server/__init__.py +0 -0
  3. openhands_agent_server-1.0.0/openhands/agent_server/__main__.py +54 -0
  4. openhands_agent_server-1.0.0/openhands/agent_server/api.py +296 -0
  5. openhands_agent_server-1.0.0/openhands/agent_server/bash_router.py +105 -0
  6. openhands_agent_server-1.0.0/openhands/agent_server/bash_service.py +380 -0
  7. openhands_agent_server-1.0.0/openhands/agent_server/config.py +165 -0
  8. openhands_agent_server-1.0.0/openhands/agent_server/conversation_router.py +273 -0
  9. openhands_agent_server-1.0.0/openhands/agent_server/conversation_service.py +562 -0
  10. openhands_agent_server-1.0.0/openhands/agent_server/dependencies.py +72 -0
  11. openhands_agent_server-1.0.0/openhands/agent_server/desktop_router.py +47 -0
  12. openhands_agent_server-1.0.0/openhands/agent_server/desktop_service.py +212 -0
  13. openhands_agent_server-1.0.0/openhands/agent_server/docker/Dockerfile +227 -0
  14. openhands_agent_server-1.0.0/openhands/agent_server/docker/build.py +713 -0
  15. openhands_agent_server-1.0.0/openhands/agent_server/docker/wallpaper.svg +22 -0
  16. openhands_agent_server-1.0.0/openhands/agent_server/env_parser.py +430 -0
  17. openhands_agent_server-1.0.0/openhands/agent_server/event_router.py +186 -0
  18. openhands_agent_server-1.0.0/openhands/agent_server/event_service.py +364 -0
  19. openhands_agent_server-1.0.0/openhands/agent_server/file_router.py +121 -0
  20. openhands_agent_server-1.0.0/openhands/agent_server/git_router.py +34 -0
  21. openhands_agent_server-1.0.0/openhands/agent_server/logging_config.py +98 -0
  22. openhands_agent_server-1.0.0/openhands/agent_server/middleware.py +32 -0
  23. openhands_agent_server-1.0.0/openhands/agent_server/models.py +241 -0
  24. openhands_agent_server-1.0.0/openhands/agent_server/openapi.py +21 -0
  25. openhands_agent_server-1.0.0/openhands/agent_server/pub_sub.py +80 -0
  26. openhands_agent_server-1.0.0/openhands/agent_server/py.typed +0 -0
  27. openhands_agent_server-1.0.0/openhands/agent_server/server_details_router.py +43 -0
  28. openhands_agent_server-1.0.0/openhands/agent_server/sockets.py +184 -0
  29. openhands_agent_server-1.0.0/openhands/agent_server/tool_router.py +18 -0
  30. openhands_agent_server-1.0.0/openhands/agent_server/utils.py +191 -0
  31. openhands_agent_server-1.0.0/openhands/agent_server/vscode_extensions/openhands-settings/extension.js +22 -0
  32. openhands_agent_server-1.0.0/openhands/agent_server/vscode_extensions/openhands-settings/package.json +12 -0
  33. openhands_agent_server-1.0.0/openhands/agent_server/vscode_router.py +70 -0
  34. openhands_agent_server-1.0.0/openhands/agent_server/vscode_service.py +232 -0
  35. openhands_agent_server-1.0.0/openhands_agent_server.egg-info/PKG-INFO +14 -0
  36. openhands_agent_server-1.0.0/openhands_agent_server.egg-info/SOURCES.txt +73 -0
  37. openhands_agent_server-1.0.0/openhands_agent_server.egg-info/dependency_links.txt +1 -0
  38. openhands_agent_server-1.0.0/openhands_agent_server.egg-info/entry_points.txt +2 -0
  39. openhands_agent_server-1.0.0/openhands_agent_server.egg-info/requires.txt +9 -0
  40. openhands_agent_server-1.0.0/openhands_agent_server.egg-info/top_level.txt +1 -0
  41. openhands_agent_server-1.0.0/pyproject.toml +42 -0
  42. openhands_agent_server-1.0.0/setup.cfg +4 -0
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.4
2
+ Name: openhands-agent-server
3
+ Version: 1.0.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,296 @@
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.utils import patch_fastapi_discriminated_union_support
33
+ from openhands.agent_server.vscode_router import vscode_router
34
+ from openhands.agent_server.vscode_service import get_vscode_service
35
+ from openhands.sdk.logger import DEBUG, get_logger
36
+
37
+
38
+ # Apply FastAPI patch for discriminated union support
39
+ patch_fastapi_discriminated_union_support()
40
+
41
+ logger = get_logger(__name__)
42
+
43
+
44
+ @asynccontextmanager
45
+ async def api_lifespan(api: FastAPI) -> AsyncIterator[None]:
46
+ service = get_default_conversation_service()
47
+ vscode_service = get_vscode_service()
48
+ desktop_service = get_desktop_service()
49
+
50
+ # Start VSCode service if enabled
51
+ if vscode_service is not None:
52
+ vscode_started = await vscode_service.start()
53
+ if vscode_started:
54
+ logger.info("VSCode service started successfully")
55
+ else:
56
+ logger.warning("VSCode service failed to start, continuing without VSCode")
57
+ else:
58
+ logger.info("VSCode service is disabled")
59
+
60
+ # Start Desktop service if enabled
61
+ if desktop_service is not None:
62
+ desktop_started = await desktop_service.start()
63
+ if desktop_started:
64
+ logger.info("Desktop service started successfully")
65
+ else:
66
+ logger.warning(
67
+ "Desktop service failed to start, continuing without desktop"
68
+ )
69
+ else:
70
+ logger.info("Desktop service is disabled")
71
+
72
+ async with service:
73
+ # Store the initialized service in app state for dependency injection
74
+ api.state.conversation_service = service
75
+ try:
76
+ yield
77
+ finally:
78
+ # Stop services on shutdown
79
+ if vscode_service is not None:
80
+ await vscode_service.stop()
81
+ if desktop_service is not None:
82
+ await desktop_service.stop()
83
+
84
+
85
+ def _create_fastapi_instance() -> FastAPI:
86
+ """Create the basic FastAPI application instance.
87
+
88
+ Returns:
89
+ Basic FastAPI application with title, description, and lifespan.
90
+ """
91
+ return FastAPI(
92
+ title="OpenHands Agent Server",
93
+ description=(
94
+ "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
95
+ ),
96
+ lifespan=api_lifespan,
97
+ )
98
+
99
+
100
+ def _find_http_exception(exc: BaseExceptionGroup) -> HTTPException | None:
101
+ """Helper function to find HTTPException in ExceptionGroup.
102
+
103
+ Args:
104
+ exc: BaseExceptionGroup to search for HTTPException.
105
+
106
+ Returns:
107
+ HTTPException if found, None otherwise.
108
+ """
109
+ for inner_exc in exc.exceptions:
110
+ if isinstance(inner_exc, HTTPException):
111
+ return inner_exc
112
+ # Recursively search nested ExceptionGroups
113
+ if isinstance(inner_exc, BaseExceptionGroup):
114
+ found = _find_http_exception(inner_exc)
115
+ if found:
116
+ return found
117
+ return None
118
+
119
+
120
+ def _add_api_routes(app: FastAPI, config: Config) -> None:
121
+ """Add all API routes to the FastAPI application.
122
+
123
+ Args:
124
+ app: FastAPI application instance to add routes to.
125
+ """
126
+ app.include_router(server_details_router)
127
+
128
+ dependencies = []
129
+ if config.session_api_keys:
130
+ dependencies.append(Depends(create_session_api_key_dependency(config)))
131
+
132
+ api_router = APIRouter(prefix="/api", dependencies=dependencies)
133
+ api_router.include_router(event_router)
134
+ api_router.include_router(conversation_router)
135
+ api_router.include_router(tool_router)
136
+ api_router.include_router(bash_router)
137
+ api_router.include_router(git_router)
138
+ api_router.include_router(file_router)
139
+ api_router.include_router(vscode_router)
140
+ api_router.include_router(desktop_router)
141
+ app.include_router(api_router)
142
+ app.include_router(sockets_router)
143
+
144
+
145
+ def _setup_static_files(app: FastAPI, config: Config) -> None:
146
+ """Set up static file serving and root redirect if configured.
147
+
148
+ Args:
149
+ app: FastAPI application instance.
150
+ config: Configuration object containing static files settings.
151
+ """
152
+ # Only proceed if static files are configured and directory exists
153
+ if not (
154
+ config.static_files_path
155
+ and config.static_files_path.exists()
156
+ and config.static_files_path.is_dir()
157
+ ):
158
+ # Map the root path to server info if there are no static files
159
+ app.get("/")(get_server_info)
160
+ return
161
+
162
+ # Mount static files directory
163
+ app.mount(
164
+ "/static",
165
+ StaticFiles(directory=str(config.static_files_path)),
166
+ name="static",
167
+ )
168
+
169
+ # Add root redirect to static files
170
+ @app.get("/", tags=["Server Details"])
171
+ async def root_redirect():
172
+ """Redirect root endpoint to static files directory."""
173
+ # Check if index.html exists in the static directory
174
+ # We know static_files_path is not None here due to the outer condition
175
+ assert config.static_files_path is not None
176
+ index_path = config.static_files_path / "index.html"
177
+ if index_path.exists():
178
+ return RedirectResponse(url="/static/index.html", status_code=302)
179
+ else:
180
+ return RedirectResponse(url="/static/", status_code=302)
181
+
182
+
183
+ def _add_exception_handlers(api: FastAPI) -> None:
184
+ """Add exception handlers to the FastAPI application."""
185
+
186
+ @api.exception_handler(Exception)
187
+ async def _unhandled_exception_handler(
188
+ request: Request, exc: Exception
189
+ ) -> JSONResponse:
190
+ """Handle unhandled exceptions."""
191
+ # Always log that we're in the exception handler for debugging
192
+ logger.debug(
193
+ "Exception handler called for %s %s with %s: %s",
194
+ request.method,
195
+ request.url.path,
196
+ type(exc).__name__,
197
+ str(exc),
198
+ )
199
+
200
+ content = {
201
+ "detail": "Internal Server Error",
202
+ "exception": str(exc),
203
+ }
204
+ # In DEBUG mode, include stack trace in response
205
+ if DEBUG:
206
+ content["traceback"] = traceback.format_exc()
207
+ # Check if this is an HTTPException that should be handled directly
208
+ if isinstance(exc, HTTPException):
209
+ return await _http_exception_handler(request, exc)
210
+
211
+ # Check if this is a BaseExceptionGroup with HTTPExceptions
212
+ if isinstance(exc, BaseExceptionGroup):
213
+ http_exc = _find_http_exception(exc)
214
+ if http_exc:
215
+ return await _http_exception_handler(request, http_exc)
216
+ # If no HTTPException found, treat as unhandled exception
217
+ logger.error(
218
+ "Unhandled ExceptionGroup on %s %s",
219
+ request.method,
220
+ request.url.path,
221
+ exc_info=(type(exc), exc, exc.__traceback__),
222
+ )
223
+ return JSONResponse(status_code=500, content=content)
224
+
225
+ # Logs full stack trace for any unhandled error that FastAPI would
226
+ # turn into a 500
227
+ logger.error(
228
+ "Unhandled exception on %s %s",
229
+ request.method,
230
+ request.url.path,
231
+ exc_info=(type(exc), exc, exc.__traceback__),
232
+ )
233
+ return JSONResponse(status_code=500, content=content)
234
+
235
+ @api.exception_handler(HTTPException)
236
+ async def _http_exception_handler(
237
+ request: Request, exc: HTTPException
238
+ ) -> JSONResponse:
239
+ """Handle HTTPExceptions with appropriate logging."""
240
+ # Log 4xx errors at info level (expected client errors like auth failures)
241
+ if 400 <= exc.status_code < 500:
242
+ logger.info(
243
+ "HTTPException %d on %s %s: %s",
244
+ exc.status_code,
245
+ request.method,
246
+ request.url.path,
247
+ exc.detail,
248
+ )
249
+ # Log 5xx errors at error level with full traceback (server errors)
250
+ elif exc.status_code >= 500:
251
+ logger.error(
252
+ "HTTPException %d on %s %s: %s",
253
+ exc.status_code,
254
+ request.method,
255
+ request.url.path,
256
+ exc.detail,
257
+ exc_info=(type(exc), exc, exc.__traceback__),
258
+ )
259
+ content = {
260
+ "detail": "Internal Server Error",
261
+ "exception": str(exc),
262
+ }
263
+ if DEBUG:
264
+ content["traceback"] = traceback.format_exc()
265
+ # Don't leak internal details to clients for 5xx errors in production
266
+ return JSONResponse(
267
+ status_code=exc.status_code,
268
+ content=content,
269
+ )
270
+
271
+ # Return clean JSON response for all non-5xx HTTP exceptions
272
+ return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
273
+
274
+
275
+ def create_app(config: Config | None = None) -> FastAPI:
276
+ """Create and configure the FastAPI application.
277
+
278
+ Args:
279
+ config: Configuration object. If None, uses default config.
280
+
281
+ Returns:
282
+ Configured FastAPI application.
283
+ """
284
+ if config is None:
285
+ config = get_default_config()
286
+ app = _create_fastapi_instance()
287
+ _add_api_routes(app, config)
288
+ _setup_static_files(app, config)
289
+ app.add_middleware(LocalhostCORSMiddleware, allow_origins=config.allow_cors_origins)
290
+ _add_exception_handlers(app)
291
+
292
+ return app
293
+
294
+
295
+ # Create the default app instance
296
+ 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}