openhands-agent-server 1.8.2__py3-none-any.whl

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 (39) hide show
  1. openhands/agent_server/__init__.py +0 -0
  2. openhands/agent_server/__main__.py +118 -0
  3. openhands/agent_server/api.py +331 -0
  4. openhands/agent_server/bash_router.py +105 -0
  5. openhands/agent_server/bash_service.py +379 -0
  6. openhands/agent_server/config.py +187 -0
  7. openhands/agent_server/conversation_router.py +321 -0
  8. openhands/agent_server/conversation_service.py +692 -0
  9. openhands/agent_server/dependencies.py +72 -0
  10. openhands/agent_server/desktop_router.py +47 -0
  11. openhands/agent_server/desktop_service.py +212 -0
  12. openhands/agent_server/docker/Dockerfile +244 -0
  13. openhands/agent_server/docker/build.py +825 -0
  14. openhands/agent_server/docker/wallpaper.svg +22 -0
  15. openhands/agent_server/env_parser.py +460 -0
  16. openhands/agent_server/event_router.py +204 -0
  17. openhands/agent_server/event_service.py +648 -0
  18. openhands/agent_server/file_router.py +121 -0
  19. openhands/agent_server/git_router.py +34 -0
  20. openhands/agent_server/logging_config.py +56 -0
  21. openhands/agent_server/middleware.py +32 -0
  22. openhands/agent_server/models.py +307 -0
  23. openhands/agent_server/openapi.py +21 -0
  24. openhands/agent_server/pub_sub.py +80 -0
  25. openhands/agent_server/py.typed +0 -0
  26. openhands/agent_server/server_details_router.py +43 -0
  27. openhands/agent_server/sockets.py +173 -0
  28. openhands/agent_server/tool_preload_service.py +76 -0
  29. openhands/agent_server/tool_router.py +22 -0
  30. openhands/agent_server/utils.py +63 -0
  31. openhands/agent_server/vscode_extensions/openhands-settings/extension.js +22 -0
  32. openhands/agent_server/vscode_extensions/openhands-settings/package.json +12 -0
  33. openhands/agent_server/vscode_router.py +70 -0
  34. openhands/agent_server/vscode_service.py +232 -0
  35. openhands_agent_server-1.8.2.dist-info/METADATA +15 -0
  36. openhands_agent_server-1.8.2.dist-info/RECORD +39 -0
  37. openhands_agent_server-1.8.2.dist-info/WHEEL +5 -0
  38. openhands_agent_server-1.8.2.dist-info/entry_points.txt +2 -0
  39. openhands_agent_server-1.8.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,121 @@
1
+ from pathlib import Path
2
+ from typing import Annotated
3
+ from uuid import UUID
4
+
5
+ from fastapi import (
6
+ APIRouter,
7
+ File,
8
+ HTTPException,
9
+ Path as FastApiPath,
10
+ UploadFile,
11
+ status,
12
+ )
13
+ from fastapi.responses import FileResponse
14
+ from starlette.background import BackgroundTask
15
+
16
+ from openhands.agent_server.bash_service import get_default_bash_event_service
17
+ from openhands.agent_server.config import get_default_config
18
+ from openhands.agent_server.conversation_service import get_default_conversation_service
19
+ from openhands.agent_server.models import ExecuteBashRequest, Success
20
+ from openhands.sdk.logger import get_logger
21
+
22
+
23
+ logger = get_logger(__name__)
24
+ file_router = APIRouter(prefix="/file", tags=["Files"])
25
+ config = get_default_config()
26
+ conversation_service = get_default_conversation_service()
27
+ bash_event_service = get_default_bash_event_service()
28
+
29
+
30
+ @file_router.post("/upload/{path:path}")
31
+ async def upload_file(
32
+ path: Annotated[str, FastApiPath(alias="path", description="Absolute file path.")],
33
+ file: Annotated[UploadFile, File(...)],
34
+ ) -> Success:
35
+ """Upload a file to the workspace."""
36
+ logger.info(f"Uploading file: {path}")
37
+ try:
38
+ target_path = Path(path)
39
+ if not target_path.is_absolute():
40
+ raise HTTPException(
41
+ status_code=status.HTTP_400_BAD_REQUEST,
42
+ detail="Path must be absolute",
43
+ )
44
+
45
+ # Ensure target directory exists
46
+ target_path.parent.mkdir(parents=True, exist_ok=True)
47
+
48
+ # Stream the file to disk to avoid memory issues with large files
49
+ with open(target_path, "wb") as f:
50
+ while chunk := await file.read(8192): # Read in 8KB chunks
51
+ f.write(chunk)
52
+
53
+ logger.info(f"Uploaded file to {target_path}")
54
+ return Success()
55
+
56
+ except Exception as e:
57
+ logger.error(f"Failed to upload file: {e}")
58
+ raise HTTPException(
59
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
60
+ detail=f"Failed to upload file: {str(e)}",
61
+ )
62
+
63
+
64
+ @file_router.get("/download/{path:path}")
65
+ async def download_file(
66
+ path: Annotated[str, FastApiPath(description="Absolute file path.")],
67
+ ) -> FileResponse:
68
+ """Download a file from the workspace."""
69
+ logger.info(f"Downloading file: {path}")
70
+ try:
71
+ target_path = Path(path)
72
+ if not target_path.is_absolute():
73
+ raise HTTPException(
74
+ status_code=status.HTTP_400_BAD_REQUEST,
75
+ detail="Path must be absolute",
76
+ )
77
+
78
+ if not target_path.exists():
79
+ raise HTTPException(
80
+ status_code=status.HTTP_404_NOT_FOUND, detail="File not found"
81
+ )
82
+
83
+ if not target_path.is_file():
84
+ raise HTTPException(
85
+ status_code=status.HTTP_400_BAD_REQUEST, detail="Path is not a file"
86
+ )
87
+
88
+ return FileResponse(
89
+ path=target_path,
90
+ filename=target_path.name,
91
+ media_type="application/octet-stream",
92
+ )
93
+
94
+ except HTTPException:
95
+ raise
96
+ except Exception as e:
97
+ logger.error(f"Failed to download file: {e}")
98
+ raise HTTPException(
99
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
100
+ detail=f"Failed to download file: {str(e)}",
101
+ )
102
+
103
+
104
+ @file_router.get("/download-trajectory/{conversation_id}")
105
+ async def download_trajectory(
106
+ conversation_id: UUID,
107
+ ) -> FileResponse:
108
+ """Download a file from the workspace."""
109
+ config = get_default_config()
110
+ temp_file = config.conversations_path / f"{conversation_id.hex}.zip"
111
+ conversation_dir = config.conversations_path / conversation_id.hex
112
+ _, task = await bash_event_service.start_bash_command(
113
+ ExecuteBashRequest(command=f"zip -r {temp_file} {conversation_dir}")
114
+ )
115
+ await task
116
+ return FileResponse(
117
+ path=temp_file,
118
+ filename=temp_file.name,
119
+ media_type="application/octet-stream",
120
+ background=BackgroundTask(temp_file.unlink),
121
+ )
@@ -0,0 +1,34 @@
1
+ """Git router for OpenHands SDK."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from pathlib import Path
6
+
7
+ from fastapi import APIRouter
8
+
9
+ from openhands.sdk.git.git_changes import get_git_changes
10
+ from openhands.sdk.git.git_diff import get_git_diff
11
+ from openhands.sdk.git.models import GitChange, GitDiff
12
+
13
+
14
+ git_router = APIRouter(prefix="/git", tags=["Git"])
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ @git_router.get("/changes/{path:path}")
19
+ async def git_changes(
20
+ path: Path,
21
+ ) -> list[GitChange]:
22
+ loop = asyncio.get_running_loop()
23
+ changes = await loop.run_in_executor(None, get_git_changes, path)
24
+ return changes
25
+
26
+
27
+ # bash event routes
28
+ @git_router.get("/diff/{path:path}")
29
+ async def git_diff(
30
+ path: Path,
31
+ ) -> GitDiff:
32
+ loop = asyncio.get_running_loop()
33
+ changes = await loop.run_in_executor(None, get_git_diff, path)
34
+ return changes
@@ -0,0 +1,56 @@
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 openhands.sdk.logger import ENV_LOG_LEVEL
7
+
8
+
9
+ def get_uvicorn_logging_config() -> dict[str, Any]:
10
+ """
11
+ Generate uvicorn logging configuration that integrates with SDK's root logger.
12
+
13
+ This function creates a logging configuration that:
14
+ 1. Preserves the SDK's root logger configuration
15
+ 2. Routes uvicorn logs through the same handlers
16
+ 3. Keeps access logs separate for better formatting
17
+ 4. Supports both Rich and JSON output based on SDK settings
18
+ """
19
+ # Base configuration
20
+ # Note: We cannot use incremental=True with partial handler definitions
21
+ # So we set it to False but configure loggers to propagate to root
22
+ config = {
23
+ "version": 1,
24
+ "disable_existing_loggers": False,
25
+ "incremental": False, # Cannot be True when defining new handlers
26
+ "formatters": {},
27
+ "handlers": {},
28
+ "loggers": {},
29
+ }
30
+
31
+ # Configure uvicorn loggers
32
+ config["loggers"] = {
33
+ # Main uvicorn logger - propagate to root
34
+ "uvicorn": {
35
+ "handlers": [], # Use root's handlers via propagation
36
+ "level": logging.getLevelName(ENV_LOG_LEVEL),
37
+ "propagate": True,
38
+ },
39
+ # Error logger - propagate to root for stack traces
40
+ "uvicorn.error": {
41
+ "handlers": [], # Use root's handlers via propagation
42
+ "level": logging.getLevelName(ENV_LOG_LEVEL),
43
+ "propagate": True,
44
+ },
45
+ # Access logger - propagate to root
46
+ "uvicorn.access": {
47
+ "handlers": [],
48
+ "level": logging.getLevelName(ENV_LOG_LEVEL),
49
+ "propagate": True,
50
+ },
51
+ }
52
+
53
+ return config
54
+
55
+
56
+ LOGGING_CONFIG = get_uvicorn_logging_config()
@@ -0,0 +1,32 @@
1
+ from urllib.parse import urlparse
2
+
3
+ from fastapi.middleware.cors import CORSMiddleware
4
+ from starlette.types import ASGIApp
5
+
6
+
7
+ 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.
10
+ """
11
+
12
+ def __init__(self, app: ASGIApp, allow_origins: list[str]) -> None:
13
+ super().__init__(
14
+ app,
15
+ allow_origins=allow_origins,
16
+ allow_credentials=True,
17
+ allow_methods=["*"],
18
+ allow_headers=["*"],
19
+ )
20
+
21
+ def is_allowed_origin(self, origin: str) -> bool:
22
+ if origin and not self.allow_origins and not self.allow_origin_regex:
23
+ parsed = urlparse(origin)
24
+ hostname = parsed.hostname or ""
25
+
26
+ # Allow any localhost/127.0.0.1 origin regardless of port
27
+ if hostname in ["localhost", "127.0.0.1"]:
28
+ return True
29
+
30
+ # For missing origin or other origins, use the parent class's logic
31
+ result: bool = super().is_allowed_origin(origin)
32
+ return result
@@ -0,0 +1,307 @@
1
+ from abc import ABC
2
+ from datetime import datetime
3
+ from enum import Enum
4
+ from typing import Any, Literal
5
+ from uuid import uuid4
6
+
7
+ from pydantic import BaseModel, Field, field_validator
8
+
9
+ from openhands.agent_server.utils import OpenHandsUUID, utc_now
10
+ from openhands.sdk import LLM, AgentBase, Event, ImageContent, Message, TextContent
11
+ from openhands.sdk.conversation.state import (
12
+ ConversationExecutionStatus,
13
+ ConversationState,
14
+ )
15
+ from openhands.sdk.llm.utils.metrics import MetricsSnapshot
16
+ from openhands.sdk.secret import SecretSource
17
+ from openhands.sdk.security.analyzer import SecurityAnalyzerBase
18
+ from openhands.sdk.security.confirmation_policy import (
19
+ ConfirmationPolicyBase,
20
+ NeverConfirm,
21
+ )
22
+ from openhands.sdk.utils.models import DiscriminatedUnionMixin, OpenHandsModel
23
+ from openhands.sdk.workspace import LocalWorkspace
24
+
25
+
26
+ class ConversationSortOrder(str, Enum):
27
+ """Enum for conversation sorting options."""
28
+
29
+ CREATED_AT = "CREATED_AT"
30
+ UPDATED_AT = "UPDATED_AT"
31
+ CREATED_AT_DESC = "CREATED_AT_DESC"
32
+ UPDATED_AT_DESC = "UPDATED_AT_DESC"
33
+
34
+
35
+ class EventSortOrder(str, Enum):
36
+ """Enum for event sorting options."""
37
+
38
+ TIMESTAMP = "TIMESTAMP"
39
+ TIMESTAMP_DESC = "TIMESTAMP_DESC"
40
+
41
+
42
+ class SendMessageRequest(BaseModel):
43
+ """Payload to send a message to the agent.
44
+
45
+ This is a simplified version of openhands.sdk.Message.
46
+ """
47
+
48
+ role: Literal["user", "system", "assistant", "tool"] = "user"
49
+ content: list[TextContent | ImageContent] = Field(default_factory=list)
50
+ run: bool = Field(
51
+ default=False,
52
+ description=("Whether the agent loop should automatically run if not running"),
53
+ )
54
+
55
+ def create_message(self) -> Message:
56
+ message = Message(role=self.role, content=self.content)
57
+ return message
58
+
59
+
60
+ class StartConversationRequest(BaseModel):
61
+ """Payload to create a new conversation.
62
+
63
+ Contains an Agent configuration along with conversation-specific options.
64
+ """
65
+
66
+ agent: AgentBase
67
+ workspace: LocalWorkspace = Field(
68
+ ...,
69
+ description="Working directory for agent operations and tool execution",
70
+ )
71
+ conversation_id: OpenHandsUUID | None = Field(
72
+ default=None,
73
+ description=(
74
+ "Optional conversation ID. If not provided, a random UUID will be "
75
+ "generated."
76
+ ),
77
+ )
78
+ confirmation_policy: ConfirmationPolicyBase = Field(
79
+ default=NeverConfirm(),
80
+ description="Controls when the conversation will prompt the user before "
81
+ "continuing. Defaults to never.",
82
+ )
83
+ initial_message: SendMessageRequest | None = Field(
84
+ default=None, description="Initial message to pass to the LLM"
85
+ )
86
+ max_iterations: int = Field(
87
+ default=500,
88
+ ge=1,
89
+ description="If set, the max number of iterations the agent will run "
90
+ "before stopping. This is useful to prevent infinite loops.",
91
+ )
92
+ stuck_detection: bool = Field(
93
+ default=True,
94
+ description="If true, the conversation will use stuck detection to "
95
+ "prevent infinite loops.",
96
+ )
97
+ secrets: dict[str, SecretSource] = Field(
98
+ default_factory=dict,
99
+ description="Secrets available in the conversation",
100
+ )
101
+ tool_module_qualnames: dict[str, str] = Field(
102
+ default_factory=dict,
103
+ description=(
104
+ "Mapping of tool names to their module qualnames from the client's "
105
+ "registry. These modules will be dynamically imported on the server "
106
+ "to register the tools for this conversation."
107
+ ),
108
+ )
109
+
110
+
111
+ class StoredConversation(StartConversationRequest):
112
+ """Stored details about a conversation"""
113
+
114
+ id: OpenHandsUUID
115
+ title: str | None = Field(
116
+ default=None, description="User-defined title for the conversation"
117
+ )
118
+ metrics: MetricsSnapshot | None = None
119
+ created_at: datetime = Field(default_factory=utc_now)
120
+ updated_at: datetime = Field(default_factory=utc_now)
121
+
122
+
123
+ class ConversationInfo(ConversationState):
124
+ """Information about a conversation running locally without a Runtime sandbox."""
125
+
126
+ # ConversationState already includes id and agent
127
+ # Add additional metadata fields
128
+
129
+ title: str | None = Field(
130
+ default=None, description="User-defined title for the conversation"
131
+ )
132
+ metrics: MetricsSnapshot | None = None
133
+ created_at: datetime = Field(default_factory=utc_now)
134
+ updated_at: datetime = Field(default_factory=utc_now)
135
+
136
+
137
+ class ConversationPage(BaseModel):
138
+ items: list[ConversationInfo]
139
+ next_page_id: str | None = None
140
+
141
+
142
+ class ConversationResponse(BaseModel):
143
+ conversation_id: str
144
+ state: ConversationExecutionStatus
145
+
146
+
147
+ class ConfirmationResponseRequest(BaseModel):
148
+ """Payload to accept or reject a pending action."""
149
+
150
+ accept: bool
151
+ reason: str = "User rejected the action."
152
+
153
+
154
+ class Success(BaseModel):
155
+ success: bool = True
156
+
157
+
158
+ class EventPage(OpenHandsModel):
159
+ items: list[Event]
160
+ next_page_id: str | None = None
161
+
162
+
163
+ class UpdateSecretsRequest(BaseModel):
164
+ """Payload to update secrets in a conversation."""
165
+
166
+ secrets: dict[str, SecretSource] = Field(
167
+ description="Dictionary mapping secret keys to values"
168
+ )
169
+
170
+ @field_validator("secrets", mode="before")
171
+ @classmethod
172
+ def convert_string_secrets(cls, v: dict[str, Any]) -> dict[str, Any]:
173
+ """Convert plain string secrets to StaticSecret objects.
174
+
175
+ This validator enables backward compatibility by automatically converting:
176
+ - Plain strings: "secret-value" → StaticSecret(value=SecretStr("secret-value"))
177
+ - Dict with value field: {"value": "secret-value"} → StaticSecret dict format
178
+ - Proper SecretSource objects: passed through unchanged
179
+ """
180
+ if not isinstance(v, dict):
181
+ return v
182
+
183
+ converted = {}
184
+ for key, value in v.items():
185
+ if isinstance(value, str):
186
+ # Convert plain string to StaticSecret dict format
187
+ converted[key] = {
188
+ "kind": "StaticSecret",
189
+ "value": value,
190
+ }
191
+ elif isinstance(value, dict):
192
+ if "value" in value and "kind" not in value:
193
+ # Convert dict with value field to StaticSecret dict format
194
+ converted[key] = {
195
+ "kind": "StaticSecret",
196
+ "value": value["value"],
197
+ }
198
+ else:
199
+ # Keep existing SecretSource objects or properly formatted dicts
200
+ converted[key] = value
201
+ else:
202
+ # Keep other types as-is (will likely fail validation later)
203
+ converted[key] = value
204
+
205
+ return converted
206
+
207
+
208
+ class SetConfirmationPolicyRequest(BaseModel):
209
+ """Payload to set confirmation policy for a conversation."""
210
+
211
+ policy: ConfirmationPolicyBase = Field(description="The confirmation policy to set")
212
+
213
+
214
+ class SetSecurityAnalyzerRequest(BaseModel):
215
+ "Payload to set security analyzer for a conversation"
216
+
217
+ security_analyzer: SecurityAnalyzerBase | None = Field(
218
+ description="The security analyzer to set"
219
+ )
220
+
221
+
222
+ class UpdateConversationRequest(BaseModel):
223
+ """Payload to update conversation metadata."""
224
+
225
+ title: str = Field(
226
+ ..., min_length=1, max_length=200, description="New conversation title"
227
+ )
228
+
229
+
230
+ class GenerateTitleRequest(BaseModel):
231
+ """Payload to generate a title for a conversation."""
232
+
233
+ max_length: int = Field(
234
+ default=50, ge=1, le=200, description="Maximum length of the generated title"
235
+ )
236
+ llm: LLM | None = Field(
237
+ default=None, description="Optional LLM to use for title generation"
238
+ )
239
+
240
+
241
+ class GenerateTitleResponse(BaseModel):
242
+ """Response containing the generated conversation title."""
243
+
244
+ title: str = Field(description="The generated title for the conversation")
245
+
246
+
247
+ class AskAgentRequest(BaseModel):
248
+ """Payload to ask the agent a simple question."""
249
+
250
+ question: str = Field(description="The question to ask the agent")
251
+
252
+
253
+ class AskAgentResponse(BaseModel):
254
+ """Response containing the agent's answer."""
255
+
256
+ response: str = Field(description="The agent's response to the question")
257
+
258
+
259
+ class BashEventBase(DiscriminatedUnionMixin, ABC):
260
+ """Base class for all bash event types"""
261
+
262
+ id: OpenHandsUUID = Field(default_factory=uuid4)
263
+ timestamp: datetime = Field(default_factory=utc_now)
264
+
265
+
266
+ class ExecuteBashRequest(BaseModel):
267
+ command: str = Field(description="The bash command to execute")
268
+ cwd: str | None = Field(default=None, description="The current working directory")
269
+ timeout: int = Field(
270
+ default=300,
271
+ description="The max number of seconds a command may be permitted to run.",
272
+ )
273
+
274
+
275
+ class BashCommand(BashEventBase, ExecuteBashRequest):
276
+ pass
277
+
278
+
279
+ class BashOutput(BashEventBase):
280
+ """
281
+ Output of a bash command. A single command may have multiple pieces of output
282
+ depending on how large the output is.
283
+ """
284
+
285
+ command_id: OpenHandsUUID
286
+ order: int = Field(
287
+ default=0, description="The order for this output, sequentially starting with 0"
288
+ )
289
+ exit_code: int | None = Field(
290
+ default=None, description="Exit code None implies the command is still running."
291
+ )
292
+ stdout: str | None = Field(
293
+ default=None, description="The standard output from the command"
294
+ )
295
+ stderr: str | None = Field(
296
+ default=None, description="The error output from the command"
297
+ )
298
+
299
+
300
+ class BashEventSortOrder(Enum):
301
+ TIMESTAMP = "TIMESTAMP"
302
+ TIMESTAMP_DESC = "TIMESTAMP_DESC"
303
+
304
+
305
+ class BashEventPage(OpenHandsModel):
306
+ items: list[BashEventBase]
307
+ next_page_id: str | None = None
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from openhands.agent_server.api import api
9
+
10
+
11
+ def generate_openapi_schema() -> dict[str, Any]:
12
+ """Generate an OpenAPI schema"""
13
+ openapi = api.openapi()
14
+ return openapi
15
+
16
+
17
+ if __name__ == "__main__":
18
+ schema_path = Path(os.environ["SCHEMA_PATH"])
19
+ schema = generate_openapi_schema()
20
+ schema_path.write_text(json.dumps(schema, indent=2))
21
+ print(f"Wrote {schema_path}")
@@ -0,0 +1,80 @@
1
+ import asyncio
2
+ from abc import ABC, abstractmethod
3
+ from dataclasses import dataclass, field
4
+ from typing import TypeVar
5
+ from uuid import UUID, uuid4
6
+
7
+ from openhands.sdk.logger import get_logger
8
+
9
+
10
+ logger = get_logger(__name__)
11
+
12
+ T = TypeVar("T")
13
+
14
+
15
+ class Subscriber[T](ABC):
16
+ @abstractmethod
17
+ async def __call__(self, event: T):
18
+ """Invoke this subscriber"""
19
+
20
+ async def close(self):
21
+ """Clean up this subscriber"""
22
+
23
+
24
+ @dataclass
25
+ class PubSub[T]:
26
+ """A subscription service that extends ConversationCallbackType functionality.
27
+ This class maintains a dictionary of UUIDs to ConversationCallbackType instances
28
+ and provides methods to subscribe/unsubscribe callbacks. When invoked, it calls
29
+ all registered callbacks with proper error handling.
30
+ """
31
+
32
+ _subscribers: dict[UUID, Subscriber[T]] = field(default_factory=dict)
33
+
34
+ def subscribe(self, subscriber: Subscriber[T]) -> UUID:
35
+ """Subscribe a subscriber and return its UUID for later unsubscription.
36
+ Args:
37
+ subscriber: The callback function to register
38
+ Returns:
39
+ UUID: UUID that can be used to unsubscribe this callback
40
+ """
41
+ subscriber_id = uuid4()
42
+ self._subscribers[subscriber_id] = subscriber
43
+ logger.debug(f"Subscribed subscriber with ID: {subscriber_id}")
44
+ return subscriber_id
45
+
46
+ def unsubscribe(self, subscriber_id: UUID) -> bool:
47
+ """Unsubscribe a subscriber by its UUID.
48
+ Args:
49
+ subscriber_id: The UUID returned by subscribe()
50
+ Returns:
51
+ bool: True if subscriber was found and removed, False otherwise
52
+ """
53
+ if subscriber_id in self._subscribers:
54
+ del self._subscribers[subscriber_id]
55
+ logger.debug(f"Unsubscribed subscriber with ID: {subscriber_id}")
56
+ return True
57
+ else:
58
+ logger.warning(
59
+ f"Attempted to unsubscribe unknown subscriber ID: {subscriber_id}"
60
+ )
61
+ return False
62
+
63
+ async def __call__(self, event: T) -> None:
64
+ """Invoke all registered callbacks with the given event.
65
+ Each callback is invoked in its own try/catch block to prevent
66
+ one failing callback from affecting others.
67
+ Args:
68
+ event: The event to pass to all callbacks
69
+ """
70
+ for subscriber_id, subscriber in list(self._subscribers.items()):
71
+ try:
72
+ await subscriber(event)
73
+ except Exception as e:
74
+ logger.error(f"Error in subscriber {subscriber_id}: {e}", exc_info=True)
75
+
76
+ async def close(self):
77
+ await asyncio.gather(
78
+ *[subscriber.close() for subscriber in self._subscribers.values()]
79
+ )
80
+ self._subscribers.clear()
File without changes
@@ -0,0 +1,43 @@
1
+ import time
2
+ from importlib.metadata import version
3
+
4
+ from fastapi import APIRouter
5
+ from pydantic import BaseModel
6
+
7
+
8
+ server_details_router = APIRouter(prefix="", tags=["Server Details"])
9
+ _start_time = time.time()
10
+ _last_event_time = time.time()
11
+
12
+
13
+ class ServerInfo(BaseModel):
14
+ uptime: float
15
+ idle_time: float
16
+ title: str = "OpenHands Agent Server"
17
+ version: str = version("openhands-agent-server")
18
+ docs: str = "/docs"
19
+ redoc: str = "/redoc"
20
+
21
+
22
+ def update_last_execution_time():
23
+ global _last_event_time
24
+ _last_event_time = time.time()
25
+
26
+
27
+ @server_details_router.get("/alive")
28
+ async def alive():
29
+ return {"status": "ok"}
30
+
31
+
32
+ @server_details_router.get("/health")
33
+ async def health() -> str:
34
+ return "OK"
35
+
36
+
37
+ @server_details_router.get("/server_info")
38
+ async def get_server_info() -> ServerInfo:
39
+ now = time.time()
40
+ return ServerInfo(
41
+ uptime=int(now - _start_time),
42
+ idle_time=int(now - _last_event_time),
43
+ )