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.
- openhands/agent_server/__init__.py +0 -0
- openhands/agent_server/__main__.py +118 -0
- openhands/agent_server/api.py +331 -0
- openhands/agent_server/bash_router.py +105 -0
- openhands/agent_server/bash_service.py +379 -0
- openhands/agent_server/config.py +187 -0
- openhands/agent_server/conversation_router.py +321 -0
- openhands/agent_server/conversation_service.py +692 -0
- openhands/agent_server/dependencies.py +72 -0
- openhands/agent_server/desktop_router.py +47 -0
- openhands/agent_server/desktop_service.py +212 -0
- openhands/agent_server/docker/Dockerfile +244 -0
- openhands/agent_server/docker/build.py +825 -0
- openhands/agent_server/docker/wallpaper.svg +22 -0
- openhands/agent_server/env_parser.py +460 -0
- openhands/agent_server/event_router.py +204 -0
- openhands/agent_server/event_service.py +648 -0
- openhands/agent_server/file_router.py +121 -0
- openhands/agent_server/git_router.py +34 -0
- openhands/agent_server/logging_config.py +56 -0
- openhands/agent_server/middleware.py +32 -0
- openhands/agent_server/models.py +307 -0
- openhands/agent_server/openapi.py +21 -0
- openhands/agent_server/pub_sub.py +80 -0
- openhands/agent_server/py.typed +0 -0
- openhands/agent_server/server_details_router.py +43 -0
- openhands/agent_server/sockets.py +173 -0
- openhands/agent_server/tool_preload_service.py +76 -0
- openhands/agent_server/tool_router.py +22 -0
- openhands/agent_server/utils.py +63 -0
- openhands/agent_server/vscode_extensions/openhands-settings/extension.js +22 -0
- openhands/agent_server/vscode_extensions/openhands-settings/package.json +12 -0
- openhands/agent_server/vscode_router.py +70 -0
- openhands/agent_server/vscode_service.py +232 -0
- openhands_agent_server-1.8.2.dist-info/METADATA +15 -0
- openhands_agent_server-1.8.2.dist-info/RECORD +39 -0
- openhands_agent_server-1.8.2.dist-info/WHEEL +5 -0
- openhands_agent_server-1.8.2.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|