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.
Files changed (47) hide show
  1. {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/PKG-INFO +5 -1
  2. openhands_agent_server-1.9.0/openhands/agent_server/__main__.py +118 -0
  3. {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/api.py +2 -0
  4. {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/bash_router.py +3 -0
  5. {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/config.py +23 -5
  6. {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/docker/Dockerfile +1 -0
  7. {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/docker/build.py +29 -6
  8. {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/env_parser.py +43 -3
  9. {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/event_service.py +38 -13
  10. {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/file_router.py +3 -0
  11. {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/git_router.py +3 -1
  12. openhands_agent_server-1.9.0/openhands/agent_server/logging_config.py +114 -0
  13. {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/middleware.py +10 -2
  14. openhands_agent_server-1.9.0/openhands/agent_server/skills_router.py +181 -0
  15. openhands_agent_server-1.9.0/openhands/agent_server/skills_service.py +401 -0
  16. {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/sockets.py +2 -2
  17. {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands_agent_server.egg-info/PKG-INFO +5 -1
  18. {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands_agent_server.egg-info/SOURCES.txt +4 -0
  19. {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/pyproject.toml +7 -1
  20. openhands_agent_server-1.8.1/openhands/agent_server/__main__.py +0 -54
  21. openhands_agent_server-1.8.1/openhands/agent_server/logging_config.py +0 -56
  22. {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/__init__.py +0 -0
  23. {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/bash_service.py +0 -0
  24. {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/conversation_router.py +0 -0
  25. {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/conversation_service.py +0 -0
  26. {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/dependencies.py +0 -0
  27. {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/desktop_router.py +0 -0
  28. {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/desktop_service.py +0 -0
  29. {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/docker/wallpaper.svg +0 -0
  30. {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/event_router.py +0 -0
  31. {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/models.py +0 -0
  32. {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/openapi.py +0 -0
  33. {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/pub_sub.py +0 -0
  34. {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/py.typed +0 -0
  35. {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/server_details_router.py +0 -0
  36. {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/tool_preload_service.py +0 -0
  37. {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/tool_router.py +0 -0
  38. {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/utils.py +0 -0
  39. {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/vscode_extensions/openhands-settings/extension.js +0 -0
  40. {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/vscode_extensions/openhands-settings/package.json +0 -0
  41. {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/vscode_router.py +0 -0
  42. {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands/agent_server/vscode_service.py +0 -0
  43. {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands_agent_server.egg-info/dependency_links.txt +0 -0
  44. {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands_agent_server.egg-info/entry_points.txt +0 -0
  45. {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands_agent_server.egg-info/requires.txt +0 -0
  46. {openhands_agent_server-1.8.1 → openhands_agent_server-1.9.0}/openhands_agent_server.egg-info/top_level.txt +0 -0
  47. {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.8.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
 
@@ -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)
@@ -10,22 +10,37 @@ from openhands.sdk.utils.cipher import Cipher
10
10
 
11
11
 
12
12
  # Environment variable constants
13
- SESSION_API_KEY_ENV = "SESSION_API_KEY"
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
- # Legacy fallback for compability with old runtime API
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(SESSION_API_KEY_ENV)
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
- session_api_key = os.getenv(SESSION_API_KEY_ENV)
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 ---
@@ -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.split("_tag_", 1)
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
- kept = ident[:visible_budget]
246
- return kept + suffix
268
+ ident = _truncate_ident(repo, tag, visible_budget)
269
+ return ident + suffix
247
270
 
248
271
 
249
272
  def _git_info() -> tuple[str, str]:
@@ -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
- return MISSING
282
- assert isinstance(kind, str)
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
- assert isinstance(parser_result, dict)
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
- loop.run_in_executor(None, self._conversation.run)
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
- # Send state update directly to the new subscriber
329
- try:
330
- await subscriber(state_update_event)
331
- except Exception as e:
332
- logger.error(
333
- f"Error sending initial state to subscriber "
334
- f"{subscriber_id}: {e}"
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 as e:
500
- logger.error(f"Error during conversation run: {e}")
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
- await self._pub_sub(state_update_event)
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()
@@ -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)
@@ -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()
@@ -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
- while using standard CORS rules for other origins.
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