openhands-agent-server 1.8.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.8.2/PKG-INFO +15 -0
- openhands_agent_server-1.8.2/openhands/agent_server/__init__.py +0 -0
- openhands_agent_server-1.8.2/openhands/agent_server/__main__.py +118 -0
- openhands_agent_server-1.8.2/openhands/agent_server/api.py +331 -0
- openhands_agent_server-1.8.2/openhands/agent_server/bash_router.py +105 -0
- openhands_agent_server-1.8.2/openhands/agent_server/bash_service.py +379 -0
- openhands_agent_server-1.8.2/openhands/agent_server/config.py +187 -0
- openhands_agent_server-1.8.2/openhands/agent_server/conversation_router.py +321 -0
- openhands_agent_server-1.8.2/openhands/agent_server/conversation_service.py +692 -0
- openhands_agent_server-1.8.2/openhands/agent_server/dependencies.py +72 -0
- openhands_agent_server-1.8.2/openhands/agent_server/desktop_router.py +47 -0
- openhands_agent_server-1.8.2/openhands/agent_server/desktop_service.py +212 -0
- openhands_agent_server-1.8.2/openhands/agent_server/docker/Dockerfile +244 -0
- openhands_agent_server-1.8.2/openhands/agent_server/docker/build.py +825 -0
- openhands_agent_server-1.8.2/openhands/agent_server/docker/wallpaper.svg +22 -0
- openhands_agent_server-1.8.2/openhands/agent_server/env_parser.py +460 -0
- openhands_agent_server-1.8.2/openhands/agent_server/event_router.py +204 -0
- openhands_agent_server-1.8.2/openhands/agent_server/event_service.py +648 -0
- openhands_agent_server-1.8.2/openhands/agent_server/file_router.py +121 -0
- openhands_agent_server-1.8.2/openhands/agent_server/git_router.py +34 -0
- openhands_agent_server-1.8.2/openhands/agent_server/logging_config.py +56 -0
- openhands_agent_server-1.8.2/openhands/agent_server/middleware.py +32 -0
- openhands_agent_server-1.8.2/openhands/agent_server/models.py +307 -0
- openhands_agent_server-1.8.2/openhands/agent_server/openapi.py +21 -0
- openhands_agent_server-1.8.2/openhands/agent_server/pub_sub.py +80 -0
- openhands_agent_server-1.8.2/openhands/agent_server/py.typed +0 -0
- openhands_agent_server-1.8.2/openhands/agent_server/server_details_router.py +43 -0
- openhands_agent_server-1.8.2/openhands/agent_server/sockets.py +173 -0
- openhands_agent_server-1.8.2/openhands/agent_server/tool_preload_service.py +76 -0
- openhands_agent_server-1.8.2/openhands/agent_server/tool_router.py +22 -0
- openhands_agent_server-1.8.2/openhands/agent_server/utils.py +63 -0
- openhands_agent_server-1.8.2/openhands/agent_server/vscode_extensions/openhands-settings/extension.js +22 -0
- openhands_agent_server-1.8.2/openhands/agent_server/vscode_extensions/openhands-settings/package.json +12 -0
- openhands_agent_server-1.8.2/openhands/agent_server/vscode_router.py +70 -0
- openhands_agent_server-1.8.2/openhands/agent_server/vscode_service.py +232 -0
- openhands_agent_server-1.8.2/openhands_agent_server.egg-info/PKG-INFO +15 -0
- openhands_agent_server-1.8.2/openhands_agent_server.egg-info/SOURCES.txt +75 -0
- openhands_agent_server-1.8.2/openhands_agent_server.egg-info/dependency_links.txt +1 -0
- openhands_agent_server-1.8.2/openhands_agent_server.egg-info/entry_points.txt +2 -0
- openhands_agent_server-1.8.2/openhands_agent_server.egg-info/requires.txt +10 -0
- openhands_agent_server-1.8.2/openhands_agent_server.egg-info/top_level.txt +1 -0
- openhands_agent_server-1.8.2/pyproject.toml +43 -0
- openhands_agent_server-1.8.2/setup.cfg +4 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: openhands-agent-server
|
|
3
|
+
Version: 1.8.2
|
|
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: openhands-sdk
|
|
11
|
+
Requires-Dist: pydantic>=2
|
|
12
|
+
Requires-Dist: sqlalchemy>=2
|
|
13
|
+
Requires-Dist: uvicorn>=0.31.1
|
|
14
|
+
Requires-Dist: websockets>=12
|
|
15
|
+
Requires-Dist: wsproto>=1.2.0
|
|
File without changes
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import atexit
|
|
3
|
+
import faulthandler
|
|
4
|
+
import signal
|
|
5
|
+
from types import FrameType
|
|
6
|
+
|
|
7
|
+
import uvicorn
|
|
8
|
+
from uvicorn import Config
|
|
9
|
+
|
|
10
|
+
from openhands.agent_server.logging_config import LOGGING_CONFIG
|
|
11
|
+
from openhands.sdk.logger import DEBUG, get_logger
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
logger = get_logger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LoggingServer(uvicorn.Server):
|
|
18
|
+
"""Custom uvicorn Server that logs signal handling events.
|
|
19
|
+
|
|
20
|
+
This subclass overrides handle_exit to add structured logging when
|
|
21
|
+
termination signals are received, ensuring visibility into why the
|
|
22
|
+
server is shutting down.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def handle_exit(self, sig: int, frame: FrameType | None) -> None:
|
|
26
|
+
"""Handle exit signals with logging before delegating to parent."""
|
|
27
|
+
sig_name = signal.Signals(sig).name
|
|
28
|
+
logger.info(
|
|
29
|
+
"Received signal %s (%d), shutting down...",
|
|
30
|
+
sig_name,
|
|
31
|
+
sig,
|
|
32
|
+
)
|
|
33
|
+
super().handle_exit(sig, frame)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _setup_crash_diagnostics() -> None:
|
|
37
|
+
"""Enable crash diagnostics for debugging unexpected terminations.
|
|
38
|
+
|
|
39
|
+
Note: faulthandler outputs tracebacks to stderr in plain text format,
|
|
40
|
+
not through the structured JSON logger. This is unavoidable because
|
|
41
|
+
during a segfault, Python's normal logging infrastructure is not
|
|
42
|
+
available. The plain text traceback is still valuable for debugging.
|
|
43
|
+
"""
|
|
44
|
+
faulthandler.enable()
|
|
45
|
+
|
|
46
|
+
# Register atexit handler to log normal exits
|
|
47
|
+
@atexit.register
|
|
48
|
+
def _log_exit() -> None:
|
|
49
|
+
logger.info("Process exiting via atexit handler")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def main() -> None:
|
|
53
|
+
# Set up crash diagnostics early, before any other initialization
|
|
54
|
+
_setup_crash_diagnostics()
|
|
55
|
+
|
|
56
|
+
parser = argparse.ArgumentParser(description="OpenHands Agent Server App")
|
|
57
|
+
parser.add_argument(
|
|
58
|
+
"--host", default="0.0.0.0", help="Host to bind to (default: 0.0.0.0)"
|
|
59
|
+
)
|
|
60
|
+
parser.add_argument(
|
|
61
|
+
"--port", type=int, default=8000, help="Port to bind to (default: 8000)"
|
|
62
|
+
)
|
|
63
|
+
parser.add_argument(
|
|
64
|
+
"--reload",
|
|
65
|
+
dest="reload",
|
|
66
|
+
default=False,
|
|
67
|
+
action="store_true",
|
|
68
|
+
help="Enable auto-reload (disabled by default)",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
args = parser.parse_args()
|
|
72
|
+
|
|
73
|
+
print(f"🙌 Starting OpenHands Agent Server on {args.host}:{args.port}")
|
|
74
|
+
print(f"📖 API docs will be available at http://{args.host}:{args.port}/docs")
|
|
75
|
+
print(f"🔄 Auto-reload: {'enabled' if args.reload else 'disabled'}")
|
|
76
|
+
|
|
77
|
+
# Show debug mode status
|
|
78
|
+
if DEBUG:
|
|
79
|
+
print("🐛 DEBUG mode: ENABLED (stack traces will be shown)")
|
|
80
|
+
else:
|
|
81
|
+
print("🔒 DEBUG mode: DISABLED")
|
|
82
|
+
print()
|
|
83
|
+
|
|
84
|
+
# Configure uvicorn logging based on DEBUG environment variable
|
|
85
|
+
log_level = "debug" if DEBUG else "info"
|
|
86
|
+
|
|
87
|
+
# Create uvicorn config
|
|
88
|
+
config = Config(
|
|
89
|
+
"openhands.agent_server.api:api",
|
|
90
|
+
host=args.host,
|
|
91
|
+
port=args.port,
|
|
92
|
+
reload=args.reload,
|
|
93
|
+
reload_includes=[
|
|
94
|
+
"openhands-agent-server",
|
|
95
|
+
"openhands-sdk",
|
|
96
|
+
"openhands-tools",
|
|
97
|
+
],
|
|
98
|
+
log_level=log_level,
|
|
99
|
+
log_config=LOGGING_CONFIG,
|
|
100
|
+
ws="wsproto", # Use wsproto instead of deprecated websockets implementation
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# Use custom LoggingServer to capture signal handling events
|
|
104
|
+
server = LoggingServer(config)
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
server.run()
|
|
108
|
+
except Exception:
|
|
109
|
+
logger.error("Server crashed with unexpected exception", exc_info=True)
|
|
110
|
+
raise
|
|
111
|
+
except BaseException as e:
|
|
112
|
+
# Catch SystemExit, KeyboardInterrupt, etc. - these are normal termination paths
|
|
113
|
+
logger.info("Server terminated: %s: %s", type(e).__name__, e)
|
|
114
|
+
raise
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
if __name__ == "__main__":
|
|
118
|
+
main()
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import traceback
|
|
3
|
+
from collections.abc import AsyncIterator
|
|
4
|
+
from contextlib import asynccontextmanager
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Depends, FastAPI, HTTPException
|
|
7
|
+
from fastapi.responses import JSONResponse, RedirectResponse
|
|
8
|
+
from fastapi.staticfiles import StaticFiles
|
|
9
|
+
from starlette.requests import Request
|
|
10
|
+
|
|
11
|
+
from openhands.agent_server.bash_router import bash_router
|
|
12
|
+
from openhands.agent_server.config import (
|
|
13
|
+
Config,
|
|
14
|
+
get_default_config,
|
|
15
|
+
)
|
|
16
|
+
from openhands.agent_server.conversation_router import conversation_router
|
|
17
|
+
from openhands.agent_server.conversation_service import (
|
|
18
|
+
get_default_conversation_service,
|
|
19
|
+
)
|
|
20
|
+
from openhands.agent_server.dependencies import create_session_api_key_dependency
|
|
21
|
+
from openhands.agent_server.desktop_router import desktop_router
|
|
22
|
+
from openhands.agent_server.desktop_service import get_desktop_service
|
|
23
|
+
from openhands.agent_server.event_router import event_router
|
|
24
|
+
from openhands.agent_server.file_router import file_router
|
|
25
|
+
from openhands.agent_server.git_router import git_router
|
|
26
|
+
from openhands.agent_server.middleware import LocalhostCORSMiddleware
|
|
27
|
+
from openhands.agent_server.server_details_router import (
|
|
28
|
+
get_server_info,
|
|
29
|
+
server_details_router,
|
|
30
|
+
)
|
|
31
|
+
from openhands.agent_server.sockets import sockets_router
|
|
32
|
+
from openhands.agent_server.tool_preload_service import get_tool_preload_service
|
|
33
|
+
from openhands.agent_server.tool_router import tool_router
|
|
34
|
+
from openhands.agent_server.vscode_router import vscode_router
|
|
35
|
+
from openhands.agent_server.vscode_service import get_vscode_service
|
|
36
|
+
from openhands.sdk.logger import DEBUG, get_logger
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
logger = get_logger(__name__)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@asynccontextmanager
|
|
43
|
+
async def api_lifespan(api: FastAPI) -> AsyncIterator[None]:
|
|
44
|
+
service = get_default_conversation_service()
|
|
45
|
+
vscode_service = get_vscode_service()
|
|
46
|
+
desktop_service = get_desktop_service()
|
|
47
|
+
tool_preload_service = get_tool_preload_service()
|
|
48
|
+
|
|
49
|
+
# Define async functions for starting each service
|
|
50
|
+
async def start_vscode_service():
|
|
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(
|
|
57
|
+
"VSCode service failed to start, continuing without VSCode"
|
|
58
|
+
)
|
|
59
|
+
else:
|
|
60
|
+
logger.info("VSCode service is disabled")
|
|
61
|
+
|
|
62
|
+
async def start_desktop_service():
|
|
63
|
+
if desktop_service is not None:
|
|
64
|
+
desktop_started = await desktop_service.start()
|
|
65
|
+
if desktop_started:
|
|
66
|
+
logger.info("Desktop service started successfully")
|
|
67
|
+
else:
|
|
68
|
+
logger.warning(
|
|
69
|
+
"Desktop service failed to start, continuing without desktop"
|
|
70
|
+
)
|
|
71
|
+
else:
|
|
72
|
+
logger.info("Desktop service is disabled")
|
|
73
|
+
|
|
74
|
+
async def start_tool_preload_service():
|
|
75
|
+
if tool_preload_service is not None:
|
|
76
|
+
tool_preload_started = await tool_preload_service.start()
|
|
77
|
+
if tool_preload_started:
|
|
78
|
+
logger.info("Tool preload service started successfully")
|
|
79
|
+
else:
|
|
80
|
+
logger.warning("Tool preload service failed to start - skipping")
|
|
81
|
+
else:
|
|
82
|
+
logger.info("Tool preload service is disabled")
|
|
83
|
+
|
|
84
|
+
# Start all services concurrently
|
|
85
|
+
await asyncio.gather(
|
|
86
|
+
start_vscode_service(),
|
|
87
|
+
start_desktop_service(),
|
|
88
|
+
start_tool_preload_service(),
|
|
89
|
+
return_exceptions=True,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
async with service:
|
|
93
|
+
# Store the initialized service in app state for dependency injection
|
|
94
|
+
api.state.conversation_service = service
|
|
95
|
+
try:
|
|
96
|
+
yield
|
|
97
|
+
finally:
|
|
98
|
+
# Define async functions for stopping each service
|
|
99
|
+
async def stop_vscode_service():
|
|
100
|
+
if vscode_service is not None:
|
|
101
|
+
await vscode_service.stop()
|
|
102
|
+
|
|
103
|
+
async def stop_desktop_service():
|
|
104
|
+
if desktop_service is not None:
|
|
105
|
+
await desktop_service.stop()
|
|
106
|
+
|
|
107
|
+
async def stop_tool_preload_service():
|
|
108
|
+
if tool_preload_service is not None:
|
|
109
|
+
await tool_preload_service.stop()
|
|
110
|
+
|
|
111
|
+
# Stop all services concurrently
|
|
112
|
+
await asyncio.gather(
|
|
113
|
+
stop_vscode_service(),
|
|
114
|
+
stop_desktop_service(),
|
|
115
|
+
stop_tool_preload_service(),
|
|
116
|
+
return_exceptions=True,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _create_fastapi_instance() -> FastAPI:
|
|
121
|
+
"""Create the basic FastAPI application instance.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
Basic FastAPI application with title, description, and lifespan.
|
|
125
|
+
"""
|
|
126
|
+
return FastAPI(
|
|
127
|
+
title="OpenHands Agent Server",
|
|
128
|
+
description=(
|
|
129
|
+
"OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
|
|
130
|
+
),
|
|
131
|
+
lifespan=api_lifespan,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _find_http_exception(exc: BaseExceptionGroup) -> HTTPException | None:
|
|
136
|
+
"""Helper function to find HTTPException in ExceptionGroup.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
exc: BaseExceptionGroup to search for HTTPException.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
HTTPException if found, None otherwise.
|
|
143
|
+
"""
|
|
144
|
+
for inner_exc in exc.exceptions:
|
|
145
|
+
if isinstance(inner_exc, HTTPException):
|
|
146
|
+
return inner_exc
|
|
147
|
+
# Recursively search nested ExceptionGroups
|
|
148
|
+
if isinstance(inner_exc, BaseExceptionGroup):
|
|
149
|
+
found = _find_http_exception(inner_exc)
|
|
150
|
+
if found:
|
|
151
|
+
return found
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _add_api_routes(app: FastAPI, config: Config) -> None:
|
|
156
|
+
"""Add all API routes to the FastAPI application.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
app: FastAPI application instance to add routes to.
|
|
160
|
+
"""
|
|
161
|
+
app.include_router(server_details_router)
|
|
162
|
+
|
|
163
|
+
dependencies = []
|
|
164
|
+
if config.session_api_keys:
|
|
165
|
+
dependencies.append(Depends(create_session_api_key_dependency(config)))
|
|
166
|
+
|
|
167
|
+
api_router = APIRouter(prefix="/api", dependencies=dependencies)
|
|
168
|
+
api_router.include_router(event_router)
|
|
169
|
+
api_router.include_router(conversation_router)
|
|
170
|
+
api_router.include_router(tool_router)
|
|
171
|
+
api_router.include_router(bash_router)
|
|
172
|
+
api_router.include_router(git_router)
|
|
173
|
+
api_router.include_router(file_router)
|
|
174
|
+
api_router.include_router(vscode_router)
|
|
175
|
+
api_router.include_router(desktop_router)
|
|
176
|
+
app.include_router(api_router)
|
|
177
|
+
app.include_router(sockets_router)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _setup_static_files(app: FastAPI, config: Config) -> None:
|
|
181
|
+
"""Set up static file serving and root redirect if configured.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
app: FastAPI application instance.
|
|
185
|
+
config: Configuration object containing static files settings.
|
|
186
|
+
"""
|
|
187
|
+
# Only proceed if static files are configured and directory exists
|
|
188
|
+
if not (
|
|
189
|
+
config.static_files_path
|
|
190
|
+
and config.static_files_path.exists()
|
|
191
|
+
and config.static_files_path.is_dir()
|
|
192
|
+
):
|
|
193
|
+
# Map the root path to server info if there are no static files
|
|
194
|
+
app.get("/")(get_server_info)
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
# Mount static files directory
|
|
198
|
+
app.mount(
|
|
199
|
+
"/static",
|
|
200
|
+
StaticFiles(directory=str(config.static_files_path)),
|
|
201
|
+
name="static",
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
# Add root redirect to static files
|
|
205
|
+
@app.get("/", tags=["Server Details"])
|
|
206
|
+
async def root_redirect():
|
|
207
|
+
"""Redirect root endpoint to static files directory."""
|
|
208
|
+
# Check if index.html exists in the static directory
|
|
209
|
+
# We know static_files_path is not None here due to the outer condition
|
|
210
|
+
assert config.static_files_path is not None
|
|
211
|
+
index_path = config.static_files_path / "index.html"
|
|
212
|
+
if index_path.exists():
|
|
213
|
+
return RedirectResponse(url="/static/index.html", status_code=302)
|
|
214
|
+
else:
|
|
215
|
+
return RedirectResponse(url="/static/", status_code=302)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _add_exception_handlers(api: FastAPI) -> None:
|
|
219
|
+
"""Add exception handlers to the FastAPI application."""
|
|
220
|
+
|
|
221
|
+
@api.exception_handler(Exception)
|
|
222
|
+
async def _unhandled_exception_handler(
|
|
223
|
+
request: Request, exc: Exception
|
|
224
|
+
) -> JSONResponse:
|
|
225
|
+
"""Handle unhandled exceptions."""
|
|
226
|
+
# Always log that we're in the exception handler for debugging
|
|
227
|
+
logger.debug(
|
|
228
|
+
"Exception handler called for %s %s with %s: %s",
|
|
229
|
+
request.method,
|
|
230
|
+
request.url.path,
|
|
231
|
+
type(exc).__name__,
|
|
232
|
+
str(exc),
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
content = {
|
|
236
|
+
"detail": "Internal Server Error",
|
|
237
|
+
"exception": str(exc),
|
|
238
|
+
}
|
|
239
|
+
# In DEBUG mode, include stack trace in response
|
|
240
|
+
if DEBUG:
|
|
241
|
+
content["traceback"] = traceback.format_exc()
|
|
242
|
+
# Check if this is an HTTPException that should be handled directly
|
|
243
|
+
if isinstance(exc, HTTPException):
|
|
244
|
+
return await _http_exception_handler(request, exc)
|
|
245
|
+
|
|
246
|
+
# Check if this is a BaseExceptionGroup with HTTPExceptions
|
|
247
|
+
if isinstance(exc, BaseExceptionGroup):
|
|
248
|
+
http_exc = _find_http_exception(exc)
|
|
249
|
+
if http_exc:
|
|
250
|
+
return await _http_exception_handler(request, http_exc)
|
|
251
|
+
# If no HTTPException found, treat as unhandled exception
|
|
252
|
+
logger.error(
|
|
253
|
+
"Unhandled ExceptionGroup on %s %s",
|
|
254
|
+
request.method,
|
|
255
|
+
request.url.path,
|
|
256
|
+
exc_info=(type(exc), exc, exc.__traceback__),
|
|
257
|
+
)
|
|
258
|
+
return JSONResponse(status_code=500, content=content)
|
|
259
|
+
|
|
260
|
+
# Logs full stack trace for any unhandled error that FastAPI would
|
|
261
|
+
# turn into a 500
|
|
262
|
+
logger.error(
|
|
263
|
+
"Unhandled exception on %s %s",
|
|
264
|
+
request.method,
|
|
265
|
+
request.url.path,
|
|
266
|
+
exc_info=(type(exc), exc, exc.__traceback__),
|
|
267
|
+
)
|
|
268
|
+
return JSONResponse(status_code=500, content=content)
|
|
269
|
+
|
|
270
|
+
@api.exception_handler(HTTPException)
|
|
271
|
+
async def _http_exception_handler(
|
|
272
|
+
request: Request, exc: HTTPException
|
|
273
|
+
) -> JSONResponse:
|
|
274
|
+
"""Handle HTTPExceptions with appropriate logging."""
|
|
275
|
+
# Log 4xx errors at info level (expected client errors like auth failures)
|
|
276
|
+
if 400 <= exc.status_code < 500:
|
|
277
|
+
logger.info(
|
|
278
|
+
"HTTPException %d on %s %s: %s",
|
|
279
|
+
exc.status_code,
|
|
280
|
+
request.method,
|
|
281
|
+
request.url.path,
|
|
282
|
+
exc.detail,
|
|
283
|
+
)
|
|
284
|
+
# Log 5xx errors at error level with full traceback (server errors)
|
|
285
|
+
elif exc.status_code >= 500:
|
|
286
|
+
logger.error(
|
|
287
|
+
"HTTPException %d on %s %s: %s",
|
|
288
|
+
exc.status_code,
|
|
289
|
+
request.method,
|
|
290
|
+
request.url.path,
|
|
291
|
+
exc.detail,
|
|
292
|
+
exc_info=(type(exc), exc, exc.__traceback__),
|
|
293
|
+
)
|
|
294
|
+
content = {
|
|
295
|
+
"detail": "Internal Server Error",
|
|
296
|
+
"exception": str(exc),
|
|
297
|
+
}
|
|
298
|
+
if DEBUG:
|
|
299
|
+
content["traceback"] = traceback.format_exc()
|
|
300
|
+
# Don't leak internal details to clients for 5xx errors in production
|
|
301
|
+
return JSONResponse(
|
|
302
|
+
status_code=exc.status_code,
|
|
303
|
+
content=content,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
# Return clean JSON response for all non-5xx HTTP exceptions
|
|
307
|
+
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def create_app(config: Config | None = None) -> FastAPI:
|
|
311
|
+
"""Create and configure the FastAPI application.
|
|
312
|
+
|
|
313
|
+
Args:
|
|
314
|
+
config: Configuration object. If None, uses default config.
|
|
315
|
+
|
|
316
|
+
Returns:
|
|
317
|
+
Configured FastAPI application.
|
|
318
|
+
"""
|
|
319
|
+
if config is None:
|
|
320
|
+
config = get_default_config()
|
|
321
|
+
app = _create_fastapi_instance()
|
|
322
|
+
_add_api_routes(app, config)
|
|
323
|
+
_setup_static_files(app, config)
|
|
324
|
+
app.add_middleware(LocalhostCORSMiddleware, allow_origins=config.allow_cors_origins)
|
|
325
|
+
_add_exception_handlers(app)
|
|
326
|
+
|
|
327
|
+
return app
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
# Create the default app instance
|
|
331
|
+
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}
|