openhands-agent-server 1.8.1__tar.gz → 1.9.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.
- {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/PKG-INFO +5 -1
- openhands_agent_server-1.9.0/openhands/agent_server/__main__.py +118 -0
- {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/api.py +2 -0
- {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/bash_router.py +3 -0
- {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/config.py +23 -5
- {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/docker/Dockerfile +1 -0
- {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/docker/build.py +29 -6
- {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/env_parser.py +43 -3
- {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/event_service.py +38 -13
- {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/file_router.py +3 -0
- {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/git_router.py +3 -1
- openhands_agent_server-1.9.0/openhands/agent_server/logging_config.py +114 -0
- {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/middleware.py +10 -2
- openhands_agent_server-1.9.0/openhands/agent_server/skills_router.py +181 -0
- openhands_agent_server-1.9.0/openhands/agent_server/skills_service.py +401 -0
- {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/sockets.py +2 -2
- {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands_agent_server.egg-info/PKG-INFO +5 -1
- {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands_agent_server.egg-info/SOURCES.txt +4 -0
- {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/pyproject.toml +7 -1
- openhands_agent_server-1.8.1/openhands/agent_server/__main__.py +0 -54
- openhands_agent_server-1.8.1/openhands/agent_server/logging_config.py +0 -56
- {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/__init__.py +0 -0
- {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/bash_service.py +0 -0
- {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/conversation_router.py +0 -0
- {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/conversation_service.py +0 -0
- {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/dependencies.py +0 -0
- {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/desktop_router.py +0 -0
- {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/desktop_service.py +0 -0
- {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/docker/wallpaper.svg +0 -0
- {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/event_router.py +0 -0
- {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/models.py +0 -0
- {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/openapi.py +0 -0
- {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/pub_sub.py +0 -0
- {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/py.typed +0 -0
- {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/server_details_router.py +0 -0
- {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/tool_preload_service.py +0 -0
- {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/tool_router.py +0 -0
- {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/utils.py +0 -0
- {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/vscode_extensions/openhands-settings/extension.js +0 -0
- {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/vscode_extensions/openhands-settings/package.json +0 -0
- {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/vscode_router.py +0 -0
- {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/vscode_service.py +0 -0
- {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands_agent_server.egg-info/dependency_links.txt +0 -0
- {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands_agent_server.egg-info/entry_points.txt +0 -0
- {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands_agent_server.egg-info/requires.txt +0 -0
- {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands_agent_server.egg-info/top_level.txt +0 -0
- {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/setup.cfg +0 -0
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openhands-agent-server
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.9.0
|
|
4
4
|
Summary: OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent
|
|
5
|
+
Project-URL: Source, https://github.com/OpenHands/software-agent-sdk
|
|
6
|
+
Project-URL: Homepage, https://github.com/OpenHands/software-agent-sdk
|
|
7
|
+
Project-URL: Documentation, https://docs.openhands.dev/sdk
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/OpenHands/software-agent-sdk/issues
|
|
5
9
|
Requires-Python: >=3.12
|
|
6
10
|
Requires-Dist: aiosqlite>=0.19
|
|
7
11
|
Requires-Dist: alembic>=1.13
|
|
@@ -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()
|
|
@@ -28,6 +28,7 @@ from openhands.agent_server.server_details_router import (
|
|
|
28
28
|
get_server_info,
|
|
29
29
|
server_details_router,
|
|
30
30
|
)
|
|
31
|
+
from openhands.agent_server.skills_router import skills_router
|
|
31
32
|
from openhands.agent_server.sockets import sockets_router
|
|
32
33
|
from openhands.agent_server.tool_preload_service import get_tool_preload_service
|
|
33
34
|
from openhands.agent_server.tool_router import tool_router
|
|
@@ -173,6 +174,7 @@ def _add_api_routes(app: FastAPI, config: Config) -> None:
|
|
|
173
174
|
api_router.include_router(file_router)
|
|
174
175
|
api_router.include_router(vscode_router)
|
|
175
176
|
api_router.include_router(desktop_router)
|
|
177
|
+
api_router.include_router(skills_router)
|
|
176
178
|
app.include_router(api_router)
|
|
177
179
|
app.include_router(sockets_router)
|
|
178
180
|
|
{openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/bash_router.py
RENAMED
|
@@ -21,6 +21,7 @@ from openhands.agent_server.models import (
|
|
|
21
21
|
BashOutput,
|
|
22
22
|
ExecuteBashRequest,
|
|
23
23
|
)
|
|
24
|
+
from openhands.agent_server.server_details_router import update_last_execution_time
|
|
24
25
|
|
|
25
26
|
|
|
26
27
|
bash_router = APIRouter(prefix="/bash", tags=["Bash"])
|
|
@@ -84,6 +85,7 @@ async def batch_get_bash_events(
|
|
|
84
85
|
@bash_router.post("/start_bash_command")
|
|
85
86
|
async def start_bash_command(request: ExecuteBashRequest) -> BashCommand:
|
|
86
87
|
"""Execute a bash command in the background"""
|
|
88
|
+
update_last_execution_time()
|
|
87
89
|
command, _ = await bash_event_service.start_bash_command(request)
|
|
88
90
|
return command
|
|
89
91
|
|
|
@@ -91,6 +93,7 @@ async def start_bash_command(request: ExecuteBashRequest) -> BashCommand:
|
|
|
91
93
|
@bash_router.post("/execute_bash_command")
|
|
92
94
|
async def execute_bash_command(request: ExecuteBashRequest) -> BashOutput:
|
|
93
95
|
"""Execute a bash command and wait for a result"""
|
|
96
|
+
update_last_execution_time()
|
|
94
97
|
command, task = await bash_event_service.start_bash_command(request)
|
|
95
98
|
await task
|
|
96
99
|
page = await bash_event_service.search_bash_events(command_id__eq=command.id)
|
{openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/config.py
RENAMED
|
@@ -10,22 +10,37 @@ from openhands.sdk.utils.cipher import Cipher
|
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
# Environment variable constants
|
|
13
|
-
|
|
13
|
+
V0_SESSION_API_KEY_ENV = "SESSION_API_KEY"
|
|
14
|
+
V1_SESSION_API_KEY_ENV = "OH_SESSION_API_KEYS_0"
|
|
14
15
|
ENVIRONMENT_VARIABLE_PREFIX = "OH"
|
|
15
16
|
_logger = logging.getLogger(__name__)
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
def _default_session_api_keys():
|
|
19
|
-
|
|
20
|
+
"""
|
|
21
|
+
This function exists as a fallback to using this old V0 environment
|
|
22
|
+
variable. If new V1_SESSION_API_KEYS_0 environment variable exists,
|
|
23
|
+
it is read automatically by the EnvParser and this function is never
|
|
24
|
+
called.
|
|
25
|
+
"""
|
|
20
26
|
result = []
|
|
21
|
-
session_api_key = os.getenv(
|
|
27
|
+
session_api_key = os.getenv(V0_SESSION_API_KEY_ENV)
|
|
22
28
|
if session_api_key:
|
|
23
29
|
result.append(session_api_key)
|
|
24
30
|
return result
|
|
25
31
|
|
|
26
32
|
|
|
27
33
|
def _default_secret_key() -> SecretStr | None:
|
|
28
|
-
|
|
34
|
+
"""
|
|
35
|
+
If the OH_SECRET_KEY environment variable is present, it is read by the EnvParser
|
|
36
|
+
and this function is never called. Otherwise, we fall back to using the first
|
|
37
|
+
available session_api_key - which we read from the environment.
|
|
38
|
+
We check both the V0 and V1 variables for this.
|
|
39
|
+
"""
|
|
40
|
+
session_api_key = os.getenv(V0_SESSION_API_KEY_ENV)
|
|
41
|
+
if session_api_key:
|
|
42
|
+
return SecretStr(session_api_key)
|
|
43
|
+
session_api_key = os.getenv(V1_SESSION_API_KEY_ENV)
|
|
29
44
|
if session_api_key:
|
|
30
45
|
return SecretStr(session_api_key)
|
|
31
46
|
return None
|
|
@@ -77,7 +92,10 @@ class Config(BaseModel):
|
|
|
77
92
|
description=(
|
|
78
93
|
"List of valid session API keys used to authenticate incoming requests. "
|
|
79
94
|
"Empty list implies the server will be unsecured. Any key in this list "
|
|
80
|
-
"will be accepted for authentication."
|
|
95
|
+
"will be accepted for authentication. Multiple keys are supported to "
|
|
96
|
+
"enable key rotation without service disruption - new keys can be added "
|
|
97
|
+
"to the list, then clients are updated with the new key, and finally the "
|
|
98
|
+
"old key is removed from the list. "
|
|
81
99
|
),
|
|
82
100
|
)
|
|
83
101
|
allow_cors_origins: list[str] = Field(
|
|
@@ -91,6 +91,7 @@ EXPOSE ${PORT}
|
|
|
91
91
|
# It includes additional Docker, VNC, Desktop, and VSCode Web.
|
|
92
92
|
####################################################################################
|
|
93
93
|
FROM base-image-minimal AS base-image
|
|
94
|
+
ARG USERNAME
|
|
94
95
|
|
|
95
96
|
USER root
|
|
96
97
|
# --- VSCode Web ---
|
{openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/docker/build.py
RENAMED
|
@@ -204,6 +204,32 @@ def _sanitize_branch(ref: str) -> str:
|
|
|
204
204
|
return re.sub(r"[^a-zA-Z0-9.-]+", "-", ref).lower()
|
|
205
205
|
|
|
206
206
|
|
|
207
|
+
def _truncate_ident(repo: str, tag: str, budget: int) -> str:
|
|
208
|
+
"""
|
|
209
|
+
Truncate repo+tag to fit budget, prioritizing tag preservation.
|
|
210
|
+
|
|
211
|
+
Strategy:
|
|
212
|
+
1. If both fit: return both
|
|
213
|
+
2. If tag fits but repo doesn't: truncate repo, keep full tag
|
|
214
|
+
3. If tag doesn't fit: truncate tag, discard repo
|
|
215
|
+
4. If no tag: truncate repo
|
|
216
|
+
"""
|
|
217
|
+
tag_suffix = f"_tag_{tag}" if tag else ""
|
|
218
|
+
full_ident = repo + tag_suffix
|
|
219
|
+
|
|
220
|
+
if len(full_ident) <= budget:
|
|
221
|
+
return full_ident
|
|
222
|
+
|
|
223
|
+
if not tag:
|
|
224
|
+
return repo[:budget]
|
|
225
|
+
|
|
226
|
+
if len(tag_suffix) <= budget:
|
|
227
|
+
repo_budget = budget - len(tag_suffix)
|
|
228
|
+
return repo[:repo_budget] + tag_suffix
|
|
229
|
+
|
|
230
|
+
return tag_suffix[:budget]
|
|
231
|
+
|
|
232
|
+
|
|
207
233
|
def _base_slug(image: str, max_len: int = 64) -> str:
|
|
208
234
|
"""
|
|
209
235
|
If the slug is too long, keep the most identifiable parts:
|
|
@@ -226,24 +252,21 @@ def _base_slug(image: str, max_len: int = 64) -> str:
|
|
|
226
252
|
|
|
227
253
|
# Parse components from the slug form
|
|
228
254
|
if "_tag_" in base_slug:
|
|
229
|
-
left, tag = base_slug.
|
|
255
|
+
left, tag = base_slug.rsplit("_tag_", 1) # Split on last : (rightmost tag)
|
|
230
256
|
else:
|
|
231
257
|
left, tag = base_slug, ""
|
|
232
258
|
|
|
233
259
|
parts = left.split("_s_") if left else []
|
|
234
260
|
repo = parts[-1] if parts else left # last path segment is the repo
|
|
235
261
|
|
|
236
|
-
# Reconstruct a compact, identifiable core: "<repo>[_tag_<tag>]"
|
|
237
|
-
ident = repo + (f"_tag_{tag}" if tag else "")
|
|
238
|
-
|
|
239
262
|
# Fit within budget, reserving space for the digest suffix
|
|
240
263
|
visible_budget = max_len - len(suffix)
|
|
241
264
|
assert visible_budget > 0, (
|
|
242
265
|
f"max_len too small to fit digest suffix with length {len(suffix)}"
|
|
243
266
|
)
|
|
244
267
|
|
|
245
|
-
|
|
246
|
-
return
|
|
268
|
+
ident = _truncate_ident(repo, tag, visible_budget)
|
|
269
|
+
return ident + suffix
|
|
247
270
|
|
|
248
271
|
|
|
249
272
|
def _git_info() -> tuple[str, str]:
|
{openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/env_parser.py
RENAMED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
We couldn't use pydantic-settings for this as we need complex nested types
|
|
3
3
|
and polymorphism."""
|
|
4
4
|
|
|
5
|
+
import importlib
|
|
5
6
|
import inspect
|
|
6
7
|
import json
|
|
7
8
|
import os
|
|
@@ -278,14 +279,53 @@ class DiscriminatedUnionEnvParser(EnvParser):
|
|
|
278
279
|
def from_env(self, key: str) -> JsonType:
|
|
279
280
|
kind = os.environ.get(f"{key}_KIND", MISSING)
|
|
280
281
|
if kind is MISSING:
|
|
281
|
-
|
|
282
|
-
|
|
282
|
+
# If there is exactly one kind, use it directly
|
|
283
|
+
if len(self.parsers) == 1:
|
|
284
|
+
kind = next(iter(self.parsers.keys()))
|
|
285
|
+
else:
|
|
286
|
+
return MISSING
|
|
287
|
+
# Type narrowing: kind is str here (from os.environ.get or dict keys)
|
|
288
|
+
kind = cast(str, kind)
|
|
289
|
+
|
|
290
|
+
# If kind contains dots, treat it as a full class name
|
|
291
|
+
if "." in kind:
|
|
292
|
+
kind = self._import_and_register_class(kind)
|
|
293
|
+
|
|
294
|
+
# Intentionally raise KeyError for invalid KIND - typos should fail early
|
|
283
295
|
parser = self.parsers[kind]
|
|
284
296
|
parser_result = parser.from_env(key)
|
|
285
|
-
|
|
297
|
+
# Type narrowing: discriminated union parsers always return dicts
|
|
298
|
+
parser_result = cast(dict, parser_result)
|
|
286
299
|
parser_result["kind"] = kind
|
|
287
300
|
return parser_result
|
|
288
301
|
|
|
302
|
+
def _import_and_register_class(self, full_class_name: str) -> str:
|
|
303
|
+
"""Import a class from its full module path and register its parser.
|
|
304
|
+
|
|
305
|
+
Args:
|
|
306
|
+
full_class_name: Full class path (e.g., 'mymodule.submodule.MyClass')
|
|
307
|
+
|
|
308
|
+
Returns:
|
|
309
|
+
The unqualified class name (e.g., 'MyClass')
|
|
310
|
+
"""
|
|
311
|
+
parts = full_class_name.rsplit(".", 1)
|
|
312
|
+
module_name = parts[0]
|
|
313
|
+
class_name = parts[1]
|
|
314
|
+
|
|
315
|
+
# If class already registered, just return the name
|
|
316
|
+
if class_name in self.parsers:
|
|
317
|
+
return class_name
|
|
318
|
+
|
|
319
|
+
# Import the module and get the class
|
|
320
|
+
module = importlib.import_module(module_name)
|
|
321
|
+
cls = getattr(module, class_name)
|
|
322
|
+
|
|
323
|
+
# Create and register the parser for this class
|
|
324
|
+
parser = get_env_parser(cls, _get_default_parsers())
|
|
325
|
+
self.parsers[class_name] = parser
|
|
326
|
+
|
|
327
|
+
return class_name
|
|
328
|
+
|
|
289
329
|
def to_env(self, key: str, value: Any, output: IO):
|
|
290
330
|
parser = self.parsers[value.kind]
|
|
291
331
|
parser.to_env(key, value, output)
|
|
@@ -311,7 +311,21 @@ class EventService:
|
|
|
311
311
|
with self._conversation.state as state:
|
|
312
312
|
run = state.execution_status != ConversationExecutionStatus.RUNNING
|
|
313
313
|
if run:
|
|
314
|
-
|
|
314
|
+
conversation = self._conversation
|
|
315
|
+
|
|
316
|
+
async def _run_with_error_handling():
|
|
317
|
+
try:
|
|
318
|
+
await loop.run_in_executor(None, conversation.run)
|
|
319
|
+
except Exception:
|
|
320
|
+
logger.exception("Error during conversation run from send_message")
|
|
321
|
+
|
|
322
|
+
# Fire-and-forget: This task is intentionally not tracked because
|
|
323
|
+
# send_message() is designed to return immediately after queuing the
|
|
324
|
+
# message. The conversation run happens in the background and any
|
|
325
|
+
# errors are logged. Unlike the run() method which is explicitly
|
|
326
|
+
# awaited, this pattern allows clients to send messages without
|
|
327
|
+
# blocking on the full conversation execution.
|
|
328
|
+
loop.create_task(_run_with_error_handling())
|
|
315
329
|
|
|
316
330
|
async def subscribe_to_events(self, subscriber: Subscriber[Event]) -> UUID:
|
|
317
331
|
subscriber_id = self._pub_sub.subscribe(subscriber)
|
|
@@ -319,20 +333,23 @@ class EventService:
|
|
|
319
333
|
# Send current state to the new subscriber immediately
|
|
320
334
|
if self._conversation:
|
|
321
335
|
state = self._conversation._state
|
|
336
|
+
# Create state snapshot while holding the lock to ensure consistency.
|
|
337
|
+
# ConversationStateUpdateEvent inherits from Event which has frozen=True
|
|
338
|
+
# in its model_config, making the snapshot immutable after creation.
|
|
322
339
|
with state:
|
|
323
|
-
# Create state update event with current state information
|
|
324
340
|
state_update_event = (
|
|
325
341
|
ConversationStateUpdateEvent.from_conversation_state(state)
|
|
326
342
|
)
|
|
327
343
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
344
|
+
# Send state update outside the lock - the event is frozen (immutable),
|
|
345
|
+
# so we don't need to hold the lock during the async send operation.
|
|
346
|
+
# This prevents potential deadlocks between the sync FIFOLock and async I/O.
|
|
347
|
+
try:
|
|
348
|
+
await subscriber(state_update_event)
|
|
349
|
+
except Exception as e:
|
|
350
|
+
logger.error(
|
|
351
|
+
f"Error sending initial state to subscriber {subscriber_id}: {e}"
|
|
352
|
+
)
|
|
336
353
|
|
|
337
354
|
return subscriber_id
|
|
338
355
|
|
|
@@ -425,6 +442,7 @@ class EventService:
|
|
|
425
442
|
stuck_detection=self.stored.stuck_detection,
|
|
426
443
|
visualizer=None,
|
|
427
444
|
secrets=self.stored.secrets,
|
|
445
|
+
cipher=self.cipher,
|
|
428
446
|
)
|
|
429
447
|
|
|
430
448
|
# Set confirmation mode if enabled
|
|
@@ -496,8 +514,8 @@ class EventService:
|
|
|
496
514
|
async def _run_and_publish():
|
|
497
515
|
try:
|
|
498
516
|
await loop.run_in_executor(None, conversation.run)
|
|
499
|
-
except Exception
|
|
500
|
-
logger.
|
|
517
|
+
except Exception:
|
|
518
|
+
logger.exception("Error during conversation run")
|
|
501
519
|
finally:
|
|
502
520
|
# Clear task reference and publish state update
|
|
503
521
|
self._run_task = None
|
|
@@ -629,11 +647,18 @@ class EventService:
|
|
|
629
647
|
return
|
|
630
648
|
|
|
631
649
|
state = self._conversation._state
|
|
650
|
+
# Create state snapshot while holding the lock to ensure consistency.
|
|
651
|
+
# ConversationStateUpdateEvent inherits from Event which has frozen=True
|
|
652
|
+
# in its model_config, making the snapshot immutable after creation.
|
|
632
653
|
with state:
|
|
633
654
|
state_update_event = ConversationStateUpdateEvent.from_conversation_state(
|
|
634
655
|
state
|
|
635
656
|
)
|
|
636
|
-
|
|
657
|
+
# Publish outside the lock - the event is frozen (immutable).
|
|
658
|
+
# Note: _pub_sub iterates through subscribers sequentially. If any subscriber
|
|
659
|
+
# is slow, it will delay subsequent subscribers. For high-throughput scenarios,
|
|
660
|
+
# consider using asyncio.gather() for concurrent notification in the future.
|
|
661
|
+
await self._pub_sub(state_update_event)
|
|
637
662
|
|
|
638
663
|
async def __aenter__(self):
|
|
639
664
|
await self.start()
|
{openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/file_router.py
RENAMED
|
@@ -17,6 +17,7 @@ from openhands.agent_server.bash_service import get_default_bash_event_service
|
|
|
17
17
|
from openhands.agent_server.config import get_default_config
|
|
18
18
|
from openhands.agent_server.conversation_service import get_default_conversation_service
|
|
19
19
|
from openhands.agent_server.models import ExecuteBashRequest, Success
|
|
20
|
+
from openhands.agent_server.server_details_router import update_last_execution_time
|
|
20
21
|
from openhands.sdk.logger import get_logger
|
|
21
22
|
|
|
22
23
|
|
|
@@ -33,6 +34,7 @@ async def upload_file(
|
|
|
33
34
|
file: Annotated[UploadFile, File(...)],
|
|
34
35
|
) -> Success:
|
|
35
36
|
"""Upload a file to the workspace."""
|
|
37
|
+
update_last_execution_time()
|
|
36
38
|
logger.info(f"Uploading file: {path}")
|
|
37
39
|
try:
|
|
38
40
|
target_path = Path(path)
|
|
@@ -66,6 +68,7 @@ async def download_file(
|
|
|
66
68
|
path: Annotated[str, FastApiPath(description="Absolute file path.")],
|
|
67
69
|
) -> FileResponse:
|
|
68
70
|
"""Download a file from the workspace."""
|
|
71
|
+
update_last_execution_time()
|
|
69
72
|
logger.info(f"Downloading file: {path}")
|
|
70
73
|
try:
|
|
71
74
|
target_path = Path(path)
|
{openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/git_router.py
RENAMED
|
@@ -6,6 +6,7 @@ from pathlib import Path
|
|
|
6
6
|
|
|
7
7
|
from fastapi import APIRouter
|
|
8
8
|
|
|
9
|
+
from openhands.agent_server.server_details_router import update_last_execution_time
|
|
9
10
|
from openhands.sdk.git.git_changes import get_git_changes
|
|
10
11
|
from openhands.sdk.git.git_diff import get_git_diff
|
|
11
12
|
from openhands.sdk.git.models import GitChange, GitDiff
|
|
@@ -19,16 +20,17 @@ logger = logging.getLogger(__name__)
|
|
|
19
20
|
async def git_changes(
|
|
20
21
|
path: Path,
|
|
21
22
|
) -> list[GitChange]:
|
|
23
|
+
update_last_execution_time()
|
|
22
24
|
loop = asyncio.get_running_loop()
|
|
23
25
|
changes = await loop.run_in_executor(None, get_git_changes, path)
|
|
24
26
|
return changes
|
|
25
27
|
|
|
26
28
|
|
|
27
|
-
# bash event routes
|
|
28
29
|
@git_router.get("/diff/{path:path}")
|
|
29
30
|
async def git_diff(
|
|
30
31
|
path: Path,
|
|
31
32
|
) -> GitDiff:
|
|
33
|
+
update_last_execution_time()
|
|
32
34
|
loop = asyncio.get_running_loop()
|
|
33
35
|
changes = await loop.run_in_executor(None, get_git_diff, path)
|
|
34
36
|
return changes
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Custom logging configuration for uvicorn to reuse the SDK's root logger."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pythonjsonlogger.json import JsonFormatter
|
|
7
|
+
|
|
8
|
+
from openhands.sdk.logger import ENV_JSON, ENV_LOG_LEVEL, IN_CI
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class UvicornAccessJsonFormatter(JsonFormatter):
|
|
12
|
+
"""JSON formatter for uvicorn access logs that extracts HTTP fields.
|
|
13
|
+
|
|
14
|
+
Uvicorn access logs pass structured data in record.args as a tuple:
|
|
15
|
+
(client_addr, method, full_path, http_version, status_code)
|
|
16
|
+
|
|
17
|
+
This formatter extracts these into separate JSON fields for better
|
|
18
|
+
querying and analysis in log aggregation systems like Datadog.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def add_fields(
|
|
22
|
+
self,
|
|
23
|
+
log_data: dict[str, Any],
|
|
24
|
+
record: logging.LogRecord,
|
|
25
|
+
message_dict: dict[str, Any],
|
|
26
|
+
) -> None:
|
|
27
|
+
super().add_fields(log_data, record, message_dict)
|
|
28
|
+
|
|
29
|
+
# Extract HTTP fields from uvicorn access log args
|
|
30
|
+
# record.args is a tuple for uvicorn access logs:
|
|
31
|
+
# (client_addr, method, full_path, http_version, status_code)
|
|
32
|
+
args = record.args
|
|
33
|
+
if isinstance(args, tuple) and len(args) >= 5:
|
|
34
|
+
client_addr, method, full_path, http_version, status_code = args[:5]
|
|
35
|
+
log_data["http.client_ip"] = client_addr
|
|
36
|
+
log_data["http.method"] = method
|
|
37
|
+
log_data["http.url"] = full_path
|
|
38
|
+
log_data["http.version"] = http_version
|
|
39
|
+
# status_code from uvicorn is typically an int, but handle edge cases
|
|
40
|
+
if isinstance(status_code, int):
|
|
41
|
+
log_data["http.status_code"] = status_code
|
|
42
|
+
elif isinstance(status_code, str) and status_code.isdigit():
|
|
43
|
+
log_data["http.status_code"] = int(status_code)
|
|
44
|
+
else:
|
|
45
|
+
log_data["http.status_code"] = status_code
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_uvicorn_logging_config() -> dict[str, Any]:
|
|
49
|
+
"""
|
|
50
|
+
Generate uvicorn logging configuration that integrates with SDK's root logger.
|
|
51
|
+
|
|
52
|
+
This function creates a logging configuration that:
|
|
53
|
+
1. Preserves the SDK's root logger configuration
|
|
54
|
+
2. Routes uvicorn logs through the same handlers
|
|
55
|
+
3. Uses JSON formatter for access logs when LOG_JSON=true or in CI
|
|
56
|
+
4. Extracts HTTP fields into structured JSON attributes
|
|
57
|
+
"""
|
|
58
|
+
use_json = ENV_JSON or IN_CI
|
|
59
|
+
log_level = logging.getLevelName(ENV_LOG_LEVEL)
|
|
60
|
+
|
|
61
|
+
# Base configuration
|
|
62
|
+
config: dict[str, Any] = {
|
|
63
|
+
"version": 1,
|
|
64
|
+
"disable_existing_loggers": False,
|
|
65
|
+
"incremental": False,
|
|
66
|
+
"formatters": {},
|
|
67
|
+
"handlers": {},
|
|
68
|
+
"loggers": {
|
|
69
|
+
# Common logger configurations - propagate to root
|
|
70
|
+
"uvicorn": {
|
|
71
|
+
"handlers": [],
|
|
72
|
+
"level": log_level,
|
|
73
|
+
"propagate": True,
|
|
74
|
+
},
|
|
75
|
+
"uvicorn.error": {
|
|
76
|
+
"handlers": [],
|
|
77
|
+
"level": log_level,
|
|
78
|
+
"propagate": True,
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if use_json:
|
|
84
|
+
# Define JSON formatter for access logs with HTTP field extraction
|
|
85
|
+
config["formatters"]["access_json"] = {
|
|
86
|
+
"()": UvicornAccessJsonFormatter,
|
|
87
|
+
"fmt": "%(asctime)s %(levelname)s %(name)s %(message)s",
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
# Define handler for access logs
|
|
91
|
+
config["handlers"]["access_json"] = {
|
|
92
|
+
"class": "logging.StreamHandler",
|
|
93
|
+
"formatter": "access_json",
|
|
94
|
+
"stream": "ext://sys.stderr",
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
# Access logger uses dedicated JSON handler with HTTP field extraction
|
|
98
|
+
config["loggers"]["uvicorn.access"] = {
|
|
99
|
+
"handlers": ["access_json"],
|
|
100
|
+
"level": log_level,
|
|
101
|
+
"propagate": False, # Don't double-log
|
|
102
|
+
}
|
|
103
|
+
else:
|
|
104
|
+
# Non-JSON mode: propagate access logs to root (uses Rich handler)
|
|
105
|
+
config["loggers"]["uvicorn.access"] = {
|
|
106
|
+
"handlers": [],
|
|
107
|
+
"level": log_level,
|
|
108
|
+
"propagate": True,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return config
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
LOGGING_CONFIG = get_uvicorn_logging_config()
|
{openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/middleware.py
RENAMED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import os
|
|
1
2
|
from urllib.parse import urlparse
|
|
2
3
|
|
|
3
4
|
from fastapi.middleware.cors import CORSMiddleware
|
|
@@ -5,8 +6,10 @@ from starlette.types import ASGIApp
|
|
|
5
6
|
|
|
6
7
|
|
|
7
8
|
class LocalhostCORSMiddleware(CORSMiddleware):
|
|
8
|
-
"""Custom CORS middleware that allows any request from localhost/127.0.0.1 domains
|
|
9
|
-
|
|
9
|
+
"""Custom CORS middleware that allows any request from localhost/127.0.0.1 domains.
|
|
10
|
+
|
|
11
|
+
Also allows the DOCKER_HOST_ADDR IP, while using standard CORS rules for
|
|
12
|
+
other origins.
|
|
10
13
|
"""
|
|
11
14
|
|
|
12
15
|
def __init__(self, app: ASGIApp, allow_origins: list[str]) -> None:
|
|
@@ -27,6 +30,11 @@ class LocalhostCORSMiddleware(CORSMiddleware):
|
|
|
27
30
|
if hostname in ["localhost", "127.0.0.1"]:
|
|
28
31
|
return True
|
|
29
32
|
|
|
33
|
+
# Also allow DOCKER_HOST_ADDR if set (for remote browser access)
|
|
34
|
+
docker_host_addr = os.environ.get("DOCKER_HOST_ADDR")
|
|
35
|
+
if docker_host_addr and hostname == docker_host_addr:
|
|
36
|
+
return True
|
|
37
|
+
|
|
30
38
|
# For missing origin or other origins, use the parent class's logic
|
|
31
39
|
result: bool = super().is_allowed_origin(origin)
|
|
32
40
|
return result
|