openhands-sdk 1.7.3__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/sdk/__init__.py +111 -0
- openhands/sdk/agent/__init__.py +8 -0
- openhands/sdk/agent/agent.py +650 -0
- openhands/sdk/agent/base.py +457 -0
- openhands/sdk/agent/prompts/in_context_learning_example.j2 +169 -0
- openhands/sdk/agent/prompts/in_context_learning_example_suffix.j2 +3 -0
- openhands/sdk/agent/prompts/model_specific/anthropic_claude.j2 +3 -0
- openhands/sdk/agent/prompts/model_specific/google_gemini.j2 +1 -0
- openhands/sdk/agent/prompts/model_specific/openai_gpt/gpt-5-codex.j2 +2 -0
- openhands/sdk/agent/prompts/model_specific/openai_gpt/gpt-5.j2 +3 -0
- openhands/sdk/agent/prompts/security_policy.j2 +22 -0
- openhands/sdk/agent/prompts/security_risk_assessment.j2 +21 -0
- openhands/sdk/agent/prompts/self_documentation.j2 +15 -0
- openhands/sdk/agent/prompts/system_prompt.j2 +132 -0
- openhands/sdk/agent/prompts/system_prompt_interactive.j2 +14 -0
- openhands/sdk/agent/prompts/system_prompt_long_horizon.j2 +40 -0
- openhands/sdk/agent/prompts/system_prompt_planning.j2 +40 -0
- openhands/sdk/agent/prompts/system_prompt_tech_philosophy.j2 +122 -0
- openhands/sdk/agent/utils.py +228 -0
- openhands/sdk/context/__init__.py +28 -0
- openhands/sdk/context/agent_context.py +264 -0
- openhands/sdk/context/condenser/__init__.py +18 -0
- openhands/sdk/context/condenser/base.py +100 -0
- openhands/sdk/context/condenser/llm_summarizing_condenser.py +248 -0
- openhands/sdk/context/condenser/no_op_condenser.py +14 -0
- openhands/sdk/context/condenser/pipeline_condenser.py +56 -0
- openhands/sdk/context/condenser/prompts/summarizing_prompt.j2 +59 -0
- openhands/sdk/context/condenser/utils.py +149 -0
- openhands/sdk/context/prompts/__init__.py +6 -0
- openhands/sdk/context/prompts/prompt.py +114 -0
- openhands/sdk/context/prompts/templates/ask_agent_template.j2 +11 -0
- openhands/sdk/context/prompts/templates/skill_knowledge_info.j2 +8 -0
- openhands/sdk/context/prompts/templates/system_message_suffix.j2 +32 -0
- openhands/sdk/context/skills/__init__.py +28 -0
- openhands/sdk/context/skills/exceptions.py +11 -0
- openhands/sdk/context/skills/skill.py +720 -0
- openhands/sdk/context/skills/trigger.py +36 -0
- openhands/sdk/context/skills/types.py +48 -0
- openhands/sdk/context/view.py +503 -0
- openhands/sdk/conversation/__init__.py +40 -0
- openhands/sdk/conversation/base.py +281 -0
- openhands/sdk/conversation/conversation.py +152 -0
- openhands/sdk/conversation/conversation_stats.py +85 -0
- openhands/sdk/conversation/event_store.py +157 -0
- openhands/sdk/conversation/events_list_base.py +17 -0
- openhands/sdk/conversation/exceptions.py +50 -0
- openhands/sdk/conversation/fifo_lock.py +133 -0
- openhands/sdk/conversation/impl/__init__.py +5 -0
- openhands/sdk/conversation/impl/local_conversation.py +665 -0
- openhands/sdk/conversation/impl/remote_conversation.py +956 -0
- openhands/sdk/conversation/persistence_const.py +9 -0
- openhands/sdk/conversation/response_utils.py +41 -0
- openhands/sdk/conversation/secret_registry.py +126 -0
- openhands/sdk/conversation/serialization_diff.py +0 -0
- openhands/sdk/conversation/state.py +392 -0
- openhands/sdk/conversation/stuck_detector.py +311 -0
- openhands/sdk/conversation/title_utils.py +191 -0
- openhands/sdk/conversation/types.py +45 -0
- openhands/sdk/conversation/visualizer/__init__.py +12 -0
- openhands/sdk/conversation/visualizer/base.py +67 -0
- openhands/sdk/conversation/visualizer/default.py +373 -0
- openhands/sdk/critic/__init__.py +15 -0
- openhands/sdk/critic/base.py +38 -0
- openhands/sdk/critic/impl/__init__.py +12 -0
- openhands/sdk/critic/impl/agent_finished.py +83 -0
- openhands/sdk/critic/impl/empty_patch.py +49 -0
- openhands/sdk/critic/impl/pass_critic.py +42 -0
- openhands/sdk/event/__init__.py +42 -0
- openhands/sdk/event/base.py +149 -0
- openhands/sdk/event/condenser.py +82 -0
- openhands/sdk/event/conversation_error.py +25 -0
- openhands/sdk/event/conversation_state.py +104 -0
- openhands/sdk/event/llm_completion_log.py +39 -0
- openhands/sdk/event/llm_convertible/__init__.py +20 -0
- openhands/sdk/event/llm_convertible/action.py +139 -0
- openhands/sdk/event/llm_convertible/message.py +142 -0
- openhands/sdk/event/llm_convertible/observation.py +141 -0
- openhands/sdk/event/llm_convertible/system.py +61 -0
- openhands/sdk/event/token.py +16 -0
- openhands/sdk/event/types.py +11 -0
- openhands/sdk/event/user_action.py +21 -0
- openhands/sdk/git/exceptions.py +43 -0
- openhands/sdk/git/git_changes.py +249 -0
- openhands/sdk/git/git_diff.py +129 -0
- openhands/sdk/git/models.py +21 -0
- openhands/sdk/git/utils.py +189 -0
- openhands/sdk/hooks/__init__.py +30 -0
- openhands/sdk/hooks/config.py +180 -0
- openhands/sdk/hooks/conversation_hooks.py +227 -0
- openhands/sdk/hooks/executor.py +155 -0
- openhands/sdk/hooks/manager.py +170 -0
- openhands/sdk/hooks/types.py +40 -0
- openhands/sdk/io/__init__.py +6 -0
- openhands/sdk/io/base.py +48 -0
- openhands/sdk/io/cache.py +85 -0
- openhands/sdk/io/local.py +119 -0
- openhands/sdk/io/memory.py +54 -0
- openhands/sdk/llm/__init__.py +45 -0
- openhands/sdk/llm/exceptions/__init__.py +45 -0
- openhands/sdk/llm/exceptions/classifier.py +50 -0
- openhands/sdk/llm/exceptions/mapping.py +54 -0
- openhands/sdk/llm/exceptions/types.py +101 -0
- openhands/sdk/llm/llm.py +1140 -0
- openhands/sdk/llm/llm_registry.py +122 -0
- openhands/sdk/llm/llm_response.py +59 -0
- openhands/sdk/llm/message.py +656 -0
- openhands/sdk/llm/mixins/fn_call_converter.py +1288 -0
- openhands/sdk/llm/mixins/non_native_fc.py +97 -0
- openhands/sdk/llm/options/__init__.py +1 -0
- openhands/sdk/llm/options/chat_options.py +93 -0
- openhands/sdk/llm/options/common.py +19 -0
- openhands/sdk/llm/options/responses_options.py +67 -0
- openhands/sdk/llm/router/__init__.py +10 -0
- openhands/sdk/llm/router/base.py +117 -0
- openhands/sdk/llm/router/impl/multimodal.py +76 -0
- openhands/sdk/llm/router/impl/random.py +22 -0
- openhands/sdk/llm/streaming.py +9 -0
- openhands/sdk/llm/utils/metrics.py +312 -0
- openhands/sdk/llm/utils/model_features.py +192 -0
- openhands/sdk/llm/utils/model_info.py +90 -0
- openhands/sdk/llm/utils/model_prompt_spec.py +98 -0
- openhands/sdk/llm/utils/retry_mixin.py +128 -0
- openhands/sdk/llm/utils/telemetry.py +362 -0
- openhands/sdk/llm/utils/unverified_models.py +156 -0
- openhands/sdk/llm/utils/verified_models.py +65 -0
- openhands/sdk/logger/__init__.py +22 -0
- openhands/sdk/logger/logger.py +195 -0
- openhands/sdk/logger/rolling.py +113 -0
- openhands/sdk/mcp/__init__.py +24 -0
- openhands/sdk/mcp/client.py +76 -0
- openhands/sdk/mcp/definition.py +106 -0
- openhands/sdk/mcp/exceptions.py +19 -0
- openhands/sdk/mcp/tool.py +270 -0
- openhands/sdk/mcp/utils.py +83 -0
- openhands/sdk/observability/__init__.py +4 -0
- openhands/sdk/observability/laminar.py +166 -0
- openhands/sdk/observability/utils.py +20 -0
- openhands/sdk/py.typed +0 -0
- openhands/sdk/secret/__init__.py +19 -0
- openhands/sdk/secret/secrets.py +92 -0
- openhands/sdk/security/__init__.py +6 -0
- openhands/sdk/security/analyzer.py +111 -0
- openhands/sdk/security/confirmation_policy.py +61 -0
- openhands/sdk/security/llm_analyzer.py +29 -0
- openhands/sdk/security/risk.py +100 -0
- openhands/sdk/tool/__init__.py +34 -0
- openhands/sdk/tool/builtins/__init__.py +34 -0
- openhands/sdk/tool/builtins/finish.py +106 -0
- openhands/sdk/tool/builtins/think.py +117 -0
- openhands/sdk/tool/registry.py +184 -0
- openhands/sdk/tool/schema.py +286 -0
- openhands/sdk/tool/spec.py +39 -0
- openhands/sdk/tool/tool.py +481 -0
- openhands/sdk/utils/__init__.py +22 -0
- openhands/sdk/utils/async_executor.py +115 -0
- openhands/sdk/utils/async_utils.py +39 -0
- openhands/sdk/utils/cipher.py +68 -0
- openhands/sdk/utils/command.py +90 -0
- openhands/sdk/utils/deprecation.py +166 -0
- openhands/sdk/utils/github.py +44 -0
- openhands/sdk/utils/json.py +48 -0
- openhands/sdk/utils/models.py +570 -0
- openhands/sdk/utils/paging.py +63 -0
- openhands/sdk/utils/pydantic_diff.py +85 -0
- openhands/sdk/utils/pydantic_secrets.py +64 -0
- openhands/sdk/utils/truncate.py +117 -0
- openhands/sdk/utils/visualize.py +58 -0
- openhands/sdk/workspace/__init__.py +17 -0
- openhands/sdk/workspace/base.py +158 -0
- openhands/sdk/workspace/local.py +189 -0
- openhands/sdk/workspace/models.py +35 -0
- openhands/sdk/workspace/remote/__init__.py +8 -0
- openhands/sdk/workspace/remote/async_remote_workspace.py +149 -0
- openhands/sdk/workspace/remote/base.py +164 -0
- openhands/sdk/workspace/remote/remote_workspace_mixin.py +323 -0
- openhands/sdk/workspace/workspace.py +49 -0
- openhands_sdk-1.7.3.dist-info/METADATA +17 -0
- openhands_sdk-1.7.3.dist-info/RECORD +180 -0
- openhands_sdk-1.7.3.dist-info/WHEEL +5 -0
- openhands_sdk-1.7.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Pydantic models for workspace operation results and build types."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
TargetType = Literal["binary", "binary-minimal", "source", "source-minimal"]
|
|
9
|
+
PlatformType = Literal["linux/amd64", "linux/arm64"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CommandResult(BaseModel):
|
|
13
|
+
"""Result of executing a command in the workspace."""
|
|
14
|
+
|
|
15
|
+
command: str = Field(description="The command that was executed")
|
|
16
|
+
exit_code: int = Field(description="Exit code of the command")
|
|
17
|
+
stdout: str = Field(description="Standard output from the command")
|
|
18
|
+
stderr: str = Field(description="Standard error from the command")
|
|
19
|
+
timeout_occurred: bool = Field(
|
|
20
|
+
description="Whether the command timed out during execution"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class FileOperationResult(BaseModel):
|
|
25
|
+
"""Result of a file upload or download operation."""
|
|
26
|
+
|
|
27
|
+
success: bool = Field(description="Whether the operation was successful")
|
|
28
|
+
source_path: str = Field(description="Path to the source file")
|
|
29
|
+
destination_path: str = Field(description="Path to the destination file")
|
|
30
|
+
file_size: int | None = Field(
|
|
31
|
+
default=None, description="Size of the file in bytes (if successful)"
|
|
32
|
+
)
|
|
33
|
+
error: str | None = Field(
|
|
34
|
+
default=None, description="Error message (if operation failed)"
|
|
35
|
+
)
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
from collections.abc import Generator
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
from pydantic import PrivateAttr
|
|
7
|
+
|
|
8
|
+
from openhands.sdk.git.models import GitChange, GitDiff
|
|
9
|
+
from openhands.sdk.workspace.models import CommandResult, FileOperationResult
|
|
10
|
+
from openhands.sdk.workspace.remote.remote_workspace_mixin import RemoteWorkspaceMixin
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AsyncRemoteWorkspace(RemoteWorkspaceMixin):
|
|
14
|
+
"""Async Remote Workspace Implementation."""
|
|
15
|
+
|
|
16
|
+
_client: httpx.AsyncClient | None = PrivateAttr(default=None)
|
|
17
|
+
|
|
18
|
+
async def reset_client(self) -> None:
|
|
19
|
+
"""Reset the HTTP client to force re-initialization.
|
|
20
|
+
|
|
21
|
+
This is useful when connection parameters (host, api_key) have changed
|
|
22
|
+
and the client needs to be recreated with new values.
|
|
23
|
+
"""
|
|
24
|
+
if self._client is not None:
|
|
25
|
+
try:
|
|
26
|
+
await self._client.aclose()
|
|
27
|
+
except Exception:
|
|
28
|
+
pass
|
|
29
|
+
self._client = None
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def client(self) -> httpx.AsyncClient:
|
|
33
|
+
client = self._client
|
|
34
|
+
if client is None:
|
|
35
|
+
# Configure reasonable timeouts for HTTP requests
|
|
36
|
+
# - connect: 10 seconds to establish connection
|
|
37
|
+
# - read: 60 seconds to read response (for LLM operations)
|
|
38
|
+
# - write: 10 seconds to send request
|
|
39
|
+
# - pool: 10 seconds to get connection from pool
|
|
40
|
+
timeout = httpx.Timeout(connect=10.0, read=60.0, write=10.0, pool=10.0)
|
|
41
|
+
client = httpx.AsyncClient(
|
|
42
|
+
base_url=self.host, timeout=timeout, headers=self._headers
|
|
43
|
+
)
|
|
44
|
+
self._client = client
|
|
45
|
+
return client
|
|
46
|
+
|
|
47
|
+
async def _execute(self, generator: Generator[dict[str, Any], httpx.Response, Any]):
|
|
48
|
+
try:
|
|
49
|
+
kwargs = next(generator)
|
|
50
|
+
while True:
|
|
51
|
+
response = await self.client.request(**kwargs)
|
|
52
|
+
kwargs = generator.send(response)
|
|
53
|
+
except StopIteration as e:
|
|
54
|
+
return e.value
|
|
55
|
+
|
|
56
|
+
async def execute_command(
|
|
57
|
+
self,
|
|
58
|
+
command: str,
|
|
59
|
+
cwd: str | Path | None = None,
|
|
60
|
+
timeout: float = 30.0,
|
|
61
|
+
) -> CommandResult:
|
|
62
|
+
"""Execute a bash command on the remote system.
|
|
63
|
+
|
|
64
|
+
This method starts a bash command via the remote agent server API,
|
|
65
|
+
then polls for the output until the command completes.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
command: The bash command to execute
|
|
69
|
+
cwd: Working directory (optional)
|
|
70
|
+
timeout: Timeout in seconds
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
CommandResult: Result with stdout, stderr, exit_code, and other metadata
|
|
74
|
+
"""
|
|
75
|
+
generator = self._execute_command_generator(command, cwd, timeout)
|
|
76
|
+
result = await self._execute(generator)
|
|
77
|
+
return result
|
|
78
|
+
|
|
79
|
+
async def file_upload(
|
|
80
|
+
self,
|
|
81
|
+
source_path: str | Path,
|
|
82
|
+
destination_path: str | Path,
|
|
83
|
+
) -> FileOperationResult:
|
|
84
|
+
"""Upload a file to the remote system.
|
|
85
|
+
|
|
86
|
+
Reads the local file and sends it to the remote system via HTTP API.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
source_path: Path to the local source file
|
|
90
|
+
destination_path: Path where the file should be uploaded on remote system
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
FileOperationResult: Result with success status and metadata
|
|
94
|
+
"""
|
|
95
|
+
generator = self._file_upload_generator(source_path, destination_path)
|
|
96
|
+
result = await self._execute(generator)
|
|
97
|
+
return result
|
|
98
|
+
|
|
99
|
+
async def file_download(
|
|
100
|
+
self,
|
|
101
|
+
source_path: str | Path,
|
|
102
|
+
destination_path: str | Path,
|
|
103
|
+
) -> FileOperationResult:
|
|
104
|
+
"""Download a file from the remote system.
|
|
105
|
+
|
|
106
|
+
Requests the file from the remote system via HTTP API and saves it locally.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
source_path: Path to the source file on remote system
|
|
110
|
+
destination_path: Path where the file should be saved locally
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
FileOperationResult: Result with success status and metadata
|
|
114
|
+
"""
|
|
115
|
+
generator = self._file_download_generator(source_path, destination_path)
|
|
116
|
+
result = await self._execute(generator)
|
|
117
|
+
return result
|
|
118
|
+
|
|
119
|
+
async def git_changes(self, path: str | Path) -> list[GitChange]:
|
|
120
|
+
"""Get the git changes for the repository at the path given.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
path: Path to the git repository
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
list[GitChange]: List of changes
|
|
127
|
+
|
|
128
|
+
Raises:
|
|
129
|
+
Exception: If path is not a git repository or getting changes failed
|
|
130
|
+
"""
|
|
131
|
+
generator = self._git_changes_generator(path)
|
|
132
|
+
result = await self._execute(generator)
|
|
133
|
+
return result
|
|
134
|
+
|
|
135
|
+
async def git_diff(self, path: str | Path) -> GitDiff:
|
|
136
|
+
"""Get the git diff for the file at the path given.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
path: Path to the file
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
GitDiff: Git diff
|
|
143
|
+
|
|
144
|
+
Raises:
|
|
145
|
+
Exception: If path is not a git repository or getting diff failed
|
|
146
|
+
"""
|
|
147
|
+
generator = self._git_diff_generator(path)
|
|
148
|
+
result = await self._execute(generator)
|
|
149
|
+
return result
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
from collections.abc import Generator
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
from pydantic import PrivateAttr
|
|
7
|
+
|
|
8
|
+
from openhands.sdk.git.models import GitChange, GitDiff
|
|
9
|
+
from openhands.sdk.workspace.base import BaseWorkspace
|
|
10
|
+
from openhands.sdk.workspace.models import CommandResult, FileOperationResult
|
|
11
|
+
from openhands.sdk.workspace.remote.remote_workspace_mixin import RemoteWorkspaceMixin
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RemoteWorkspace(RemoteWorkspaceMixin, BaseWorkspace):
|
|
15
|
+
"""Remote workspace implementation that connects to an OpenHands agent server.
|
|
16
|
+
|
|
17
|
+
RemoteWorkspace provides access to a sandboxed environment running on a remote
|
|
18
|
+
OpenHands agent server. This is the recommended approach for production deployments
|
|
19
|
+
as it provides better isolation and security.
|
|
20
|
+
|
|
21
|
+
Example:
|
|
22
|
+
>>> workspace = RemoteWorkspace(
|
|
23
|
+
... host="https://agent-server.example.com",
|
|
24
|
+
... working_dir="/workspace"
|
|
25
|
+
... )
|
|
26
|
+
>>> with workspace:
|
|
27
|
+
... result = workspace.execute_command("ls -la")
|
|
28
|
+
... content = workspace.read_file("README.md")
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
_client: httpx.Client | None = PrivateAttr(default=None)
|
|
32
|
+
|
|
33
|
+
def reset_client(self) -> None:
|
|
34
|
+
"""Reset the HTTP client to force re-initialization.
|
|
35
|
+
|
|
36
|
+
This is useful when connection parameters (host, api_key) have changed
|
|
37
|
+
and the client needs to be recreated with new values.
|
|
38
|
+
"""
|
|
39
|
+
if self._client is not None:
|
|
40
|
+
try:
|
|
41
|
+
self._client.close()
|
|
42
|
+
except Exception:
|
|
43
|
+
pass
|
|
44
|
+
self._client = None
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def client(self) -> httpx.Client:
|
|
48
|
+
client = self._client
|
|
49
|
+
if client is None:
|
|
50
|
+
# Configure reasonable timeouts for HTTP requests
|
|
51
|
+
# - connect: 10 seconds to establish connection
|
|
52
|
+
# - read: 60 seconds to read response (for LLM operations)
|
|
53
|
+
# - write: 10 seconds to send request
|
|
54
|
+
# - pool: 10 seconds to get connection from pool
|
|
55
|
+
timeout = httpx.Timeout(connect=10.0, read=60.0, write=10.0, pool=10.0)
|
|
56
|
+
client = httpx.Client(
|
|
57
|
+
base_url=self.host, timeout=timeout, headers=self._headers
|
|
58
|
+
)
|
|
59
|
+
self._client = client
|
|
60
|
+
return client
|
|
61
|
+
|
|
62
|
+
def _execute(self, generator: Generator[dict[str, Any], httpx.Response, Any]):
|
|
63
|
+
try:
|
|
64
|
+
kwargs = next(generator)
|
|
65
|
+
while True:
|
|
66
|
+
response = self.client.request(**kwargs)
|
|
67
|
+
kwargs = generator.send(response)
|
|
68
|
+
except StopIteration as e:
|
|
69
|
+
return e.value
|
|
70
|
+
|
|
71
|
+
def execute_command(
|
|
72
|
+
self,
|
|
73
|
+
command: str,
|
|
74
|
+
cwd: str | Path | None = None,
|
|
75
|
+
timeout: float = 30.0,
|
|
76
|
+
) -> CommandResult:
|
|
77
|
+
"""Execute a bash command on the remote system.
|
|
78
|
+
|
|
79
|
+
This method starts a bash command via the remote agent server API,
|
|
80
|
+
then polls for the output until the command completes.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
command: The bash command to execute
|
|
84
|
+
cwd: Working directory (optional)
|
|
85
|
+
timeout: Timeout in seconds
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
CommandResult: Result with stdout, stderr, exit_code, and other metadata
|
|
89
|
+
"""
|
|
90
|
+
generator = self._execute_command_generator(command, cwd, timeout)
|
|
91
|
+
result = self._execute(generator)
|
|
92
|
+
return result
|
|
93
|
+
|
|
94
|
+
def file_upload(
|
|
95
|
+
self,
|
|
96
|
+
source_path: str | Path,
|
|
97
|
+
destination_path: str | Path,
|
|
98
|
+
) -> FileOperationResult:
|
|
99
|
+
"""Upload a file to the remote system.
|
|
100
|
+
|
|
101
|
+
Reads the local file and sends it to the remote system via HTTP API.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
source_path: Path to the local source file
|
|
105
|
+
destination_path: Path where the file should be uploaded on remote system
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
FileOperationResult: Result with success status and metadata
|
|
109
|
+
"""
|
|
110
|
+
generator = self._file_upload_generator(source_path, destination_path)
|
|
111
|
+
result = self._execute(generator)
|
|
112
|
+
return result
|
|
113
|
+
|
|
114
|
+
def file_download(
|
|
115
|
+
self,
|
|
116
|
+
source_path: str | Path,
|
|
117
|
+
destination_path: str | Path,
|
|
118
|
+
) -> FileOperationResult:
|
|
119
|
+
"""Download a file from the remote system.
|
|
120
|
+
|
|
121
|
+
Requests the file from the remote system via HTTP API and saves it locally.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
source_path: Path to the source file on remote system
|
|
125
|
+
destination_path: Path where the file should be saved locally
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
FileOperationResult: Result with success status and metadata
|
|
129
|
+
"""
|
|
130
|
+
generator = self._file_download_generator(source_path, destination_path)
|
|
131
|
+
result = self._execute(generator)
|
|
132
|
+
return result
|
|
133
|
+
|
|
134
|
+
def git_changes(self, path: str | Path) -> list[GitChange]:
|
|
135
|
+
"""Get the git changes for the repository at the path given.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
path: Path to the git repository
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
list[GitChange]: List of changes
|
|
142
|
+
|
|
143
|
+
Raises:
|
|
144
|
+
Exception: If path is not a git repository or getting changes failed
|
|
145
|
+
"""
|
|
146
|
+
generator = self._git_changes_generator(path)
|
|
147
|
+
result = self._execute(generator)
|
|
148
|
+
return result
|
|
149
|
+
|
|
150
|
+
def git_diff(self, path: str | Path) -> GitDiff:
|
|
151
|
+
"""Get the git diff for the file at the path given.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
path: Path to the file
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
GitDiff: Git diff
|
|
158
|
+
|
|
159
|
+
Raises:
|
|
160
|
+
Exception: If path is not a git repository or getting diff failed
|
|
161
|
+
"""
|
|
162
|
+
generator = self._git_diff_generator(path)
|
|
163
|
+
result = self._execute(generator)
|
|
164
|
+
return result
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import time
|
|
3
|
+
from collections.abc import Generator
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from pydantic import BaseModel, Field, TypeAdapter
|
|
9
|
+
|
|
10
|
+
from openhands.sdk.git.models import GitChange, GitDiff
|
|
11
|
+
from openhands.sdk.workspace.models import CommandResult, FileOperationResult
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RemoteWorkspaceMixin(BaseModel):
|
|
18
|
+
"""Mixin providing remote workspace operations.
|
|
19
|
+
This allows the same code to be used for sync and async."""
|
|
20
|
+
|
|
21
|
+
host: str = Field(description="The remote host URL for the workspace.")
|
|
22
|
+
api_key: str | None = Field(
|
|
23
|
+
default=None, description="API key for authenticating with the remote host."
|
|
24
|
+
)
|
|
25
|
+
working_dir: str = Field(
|
|
26
|
+
description="The working directory for agent operations and tool execution."
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def model_post_init(self, context: Any) -> None:
|
|
30
|
+
# Set up remote host
|
|
31
|
+
self.host = self.host.rstrip("/")
|
|
32
|
+
return super().model_post_init(context)
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def _headers(self):
|
|
36
|
+
headers = {}
|
|
37
|
+
if self.api_key:
|
|
38
|
+
headers["X-Session-API-Key"] = self.api_key
|
|
39
|
+
return headers
|
|
40
|
+
|
|
41
|
+
def _execute_command_generator(
|
|
42
|
+
self,
|
|
43
|
+
command: str,
|
|
44
|
+
cwd: str | Path | None,
|
|
45
|
+
timeout: float,
|
|
46
|
+
) -> Generator[dict[str, Any], httpx.Response, CommandResult]:
|
|
47
|
+
"""Execute a bash command on the remote system.
|
|
48
|
+
|
|
49
|
+
This method starts a bash command via the remote agent server API,
|
|
50
|
+
then polls for the output until the command completes.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
command: The bash command to execute
|
|
54
|
+
cwd: Working directory (optional)
|
|
55
|
+
timeout: Timeout in seconds
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
CommandResult: Result with stdout, stderr, exit_code, and other metadata
|
|
59
|
+
"""
|
|
60
|
+
_logger.debug(f"Executing remote command: {command}")
|
|
61
|
+
|
|
62
|
+
# Step 1: Start the bash command
|
|
63
|
+
payload = {
|
|
64
|
+
"command": command,
|
|
65
|
+
"timeout": int(timeout),
|
|
66
|
+
}
|
|
67
|
+
if cwd is not None:
|
|
68
|
+
payload["cwd"] = str(cwd)
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
# Start the command
|
|
72
|
+
response: httpx.Response = yield {
|
|
73
|
+
"method": "POST",
|
|
74
|
+
"url": f"{self.host}/api/bash/start_bash_command",
|
|
75
|
+
"json": payload,
|
|
76
|
+
"headers": self._headers,
|
|
77
|
+
"timeout": timeout + 5.0, # Add buffer to HTTP timeout
|
|
78
|
+
}
|
|
79
|
+
response.raise_for_status()
|
|
80
|
+
bash_command = response.json()
|
|
81
|
+
command_id = bash_command["id"]
|
|
82
|
+
|
|
83
|
+
_logger.debug(f"Started command with ID: {command_id}")
|
|
84
|
+
|
|
85
|
+
# Step 2: Poll for output until command completes
|
|
86
|
+
start_time = time.time()
|
|
87
|
+
stdout_parts = []
|
|
88
|
+
stderr_parts = []
|
|
89
|
+
exit_code = None
|
|
90
|
+
|
|
91
|
+
while time.time() - start_time < timeout:
|
|
92
|
+
# Search for all events
|
|
93
|
+
response = yield {
|
|
94
|
+
"method": "GET",
|
|
95
|
+
"url": f"{self.host}/api/bash/bash_events/search",
|
|
96
|
+
"params": {
|
|
97
|
+
"command_id__eq": command_id,
|
|
98
|
+
"sort_order": "TIMESTAMP",
|
|
99
|
+
"limit": 100,
|
|
100
|
+
},
|
|
101
|
+
"headers": self._headers,
|
|
102
|
+
"timeout": timeout,
|
|
103
|
+
}
|
|
104
|
+
response.raise_for_status()
|
|
105
|
+
search_result = response.json()
|
|
106
|
+
|
|
107
|
+
# Filter for BashOutput events for this command
|
|
108
|
+
for event in search_result.get("items", []):
|
|
109
|
+
if event.get("kind") == "BashOutput":
|
|
110
|
+
if event.get("stdout"):
|
|
111
|
+
stdout_parts.append(event["stdout"])
|
|
112
|
+
if event.get("stderr"):
|
|
113
|
+
stderr_parts.append(event["stderr"])
|
|
114
|
+
if event.get("exit_code") is not None:
|
|
115
|
+
exit_code = event["exit_code"]
|
|
116
|
+
|
|
117
|
+
# If we have an exit code, the command is complete
|
|
118
|
+
if exit_code is not None:
|
|
119
|
+
break
|
|
120
|
+
|
|
121
|
+
# Wait a bit before polling again
|
|
122
|
+
time.sleep(0.1)
|
|
123
|
+
|
|
124
|
+
# If we timed out waiting for completion
|
|
125
|
+
if exit_code is None:
|
|
126
|
+
_logger.warning(f"Command timed out after {timeout} seconds: {command}")
|
|
127
|
+
exit_code = -1
|
|
128
|
+
stderr_parts.append(f"Command timed out after {timeout} seconds")
|
|
129
|
+
|
|
130
|
+
# Combine all output parts
|
|
131
|
+
stdout = "".join(stdout_parts)
|
|
132
|
+
stderr = "".join(stderr_parts)
|
|
133
|
+
|
|
134
|
+
return CommandResult(
|
|
135
|
+
command=command,
|
|
136
|
+
exit_code=exit_code,
|
|
137
|
+
stdout=stdout,
|
|
138
|
+
stderr=stderr,
|
|
139
|
+
timeout_occurred=exit_code == -1 and "timed out" in stderr,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
except Exception as e:
|
|
143
|
+
_logger.error(f"Remote command execution failed: {e}")
|
|
144
|
+
return CommandResult(
|
|
145
|
+
command=command,
|
|
146
|
+
exit_code=-1,
|
|
147
|
+
stdout="",
|
|
148
|
+
stderr=f"Remote execution error: {str(e)}",
|
|
149
|
+
timeout_occurred=False,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
def _file_upload_generator(
|
|
153
|
+
self,
|
|
154
|
+
source_path: str | Path,
|
|
155
|
+
destination_path: str | Path,
|
|
156
|
+
) -> Generator[dict[str, Any], httpx.Response, FileOperationResult]:
|
|
157
|
+
"""Upload a file to the remote system.
|
|
158
|
+
|
|
159
|
+
Reads the local file and sends it to the remote system via HTTP API.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
source_path: Path to the local source file
|
|
163
|
+
destination_path: Path where the file should be uploaded on remote system
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
FileOperationResult: Result with success status and metadata
|
|
167
|
+
"""
|
|
168
|
+
source = Path(source_path)
|
|
169
|
+
destination = Path(destination_path)
|
|
170
|
+
|
|
171
|
+
_logger.debug(f"Remote file upload: {source} -> {destination}")
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
# Read the file content
|
|
175
|
+
with open(source, "rb") as f:
|
|
176
|
+
file_content = f.read()
|
|
177
|
+
|
|
178
|
+
# Prepare the upload
|
|
179
|
+
files = {"file": (source.name, file_content)}
|
|
180
|
+
data = {"destination_path": str(destination)}
|
|
181
|
+
|
|
182
|
+
# Make HTTP call
|
|
183
|
+
response: httpx.Response = yield {
|
|
184
|
+
"method": "POST",
|
|
185
|
+
"url": f"{self.host}/api/file/upload/{destination}",
|
|
186
|
+
"files": files,
|
|
187
|
+
"data": data,
|
|
188
|
+
"headers": self._headers,
|
|
189
|
+
"timeout": 60.0,
|
|
190
|
+
}
|
|
191
|
+
response.raise_for_status()
|
|
192
|
+
result_data = response.json()
|
|
193
|
+
|
|
194
|
+
# Convert the API response to our model
|
|
195
|
+
return FileOperationResult(
|
|
196
|
+
success=result_data.get("success", True),
|
|
197
|
+
source_path=str(source),
|
|
198
|
+
destination_path=str(destination),
|
|
199
|
+
file_size=result_data.get("file_size"),
|
|
200
|
+
error=result_data.get("error"),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
except Exception as e:
|
|
204
|
+
_logger.error(f"Remote file upload failed: {e}")
|
|
205
|
+
return FileOperationResult(
|
|
206
|
+
success=False,
|
|
207
|
+
source_path=str(source),
|
|
208
|
+
destination_path=str(destination),
|
|
209
|
+
error=str(e),
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
def _file_download_generator(
|
|
213
|
+
self,
|
|
214
|
+
source_path: str | Path,
|
|
215
|
+
destination_path: str | Path,
|
|
216
|
+
) -> Generator[dict[str, Any], httpx.Response, FileOperationResult]:
|
|
217
|
+
"""Download a file from the remote system.
|
|
218
|
+
|
|
219
|
+
Requests the file from the remote system via HTTP API and saves it locally.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
source_path: Path to the source file on remote system
|
|
223
|
+
destination_path: Path where the file should be saved locally
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
FileOperationResult: Result with success status and metadata
|
|
227
|
+
"""
|
|
228
|
+
source = Path(source_path)
|
|
229
|
+
destination = Path(destination_path)
|
|
230
|
+
|
|
231
|
+
_logger.debug(f"Remote file download: {source} -> {destination}")
|
|
232
|
+
|
|
233
|
+
try:
|
|
234
|
+
# Construct URL with path parameter (not query parameter)
|
|
235
|
+
# Double slash ensures FastAPI extracts path with leading slash
|
|
236
|
+
# for absolute path validation
|
|
237
|
+
source_str = str(source)
|
|
238
|
+
url = f"/api/file/download//{source_str.lstrip('/')}"
|
|
239
|
+
|
|
240
|
+
# Make HTTP call
|
|
241
|
+
response = yield {
|
|
242
|
+
"method": "GET",
|
|
243
|
+
"url": url,
|
|
244
|
+
"headers": self._headers,
|
|
245
|
+
"timeout": 60.0,
|
|
246
|
+
}
|
|
247
|
+
response.raise_for_status()
|
|
248
|
+
|
|
249
|
+
# Ensure destination directory exists
|
|
250
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
251
|
+
|
|
252
|
+
# Write the file content
|
|
253
|
+
with open(destination, "wb") as f:
|
|
254
|
+
f.write(response.content)
|
|
255
|
+
|
|
256
|
+
return FileOperationResult(
|
|
257
|
+
success=True,
|
|
258
|
+
source_path=str(source),
|
|
259
|
+
destination_path=str(destination),
|
|
260
|
+
file_size=len(response.content),
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
except Exception as e:
|
|
264
|
+
_logger.error(f"Remote file download failed: {e}")
|
|
265
|
+
return FileOperationResult(
|
|
266
|
+
success=False,
|
|
267
|
+
source_path=str(source),
|
|
268
|
+
destination_path=str(destination),
|
|
269
|
+
error=str(e),
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
def _git_changes_generator(
|
|
273
|
+
self,
|
|
274
|
+
path: str | Path,
|
|
275
|
+
) -> Generator[dict[str, Any], httpx.Response, list[GitChange]]:
|
|
276
|
+
"""Get the git changes for the repository at the path given.
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
path: Path to the git repository
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
list[GitChange]: List of changes
|
|
283
|
+
|
|
284
|
+
Raises:
|
|
285
|
+
Exception: If path is not a git repository or getting changes failed
|
|
286
|
+
"""
|
|
287
|
+
# Make HTTP call
|
|
288
|
+
response = yield {
|
|
289
|
+
"method": "GET",
|
|
290
|
+
"url": Path("/api/git/changes") / self.working_dir / path,
|
|
291
|
+
"headers": self._headers,
|
|
292
|
+
"timeout": 60.0,
|
|
293
|
+
}
|
|
294
|
+
response.raise_for_status()
|
|
295
|
+
type_adapter = TypeAdapter(list[GitChange])
|
|
296
|
+
changes = type_adapter.validate_python(response.json())
|
|
297
|
+
return changes
|
|
298
|
+
|
|
299
|
+
def _git_diff_generator(
|
|
300
|
+
self,
|
|
301
|
+
path: str | Path,
|
|
302
|
+
) -> Generator[dict[str, Any], httpx.Response, GitDiff]:
|
|
303
|
+
"""Get the git diff for the file at the path given.
|
|
304
|
+
|
|
305
|
+
Args:
|
|
306
|
+
path: Path to the file
|
|
307
|
+
|
|
308
|
+
Returns:
|
|
309
|
+
GitDiff: Git diff
|
|
310
|
+
|
|
311
|
+
Raises:
|
|
312
|
+
Exception: If path is not a git repository or getting diff failed
|
|
313
|
+
"""
|
|
314
|
+
# Make HTTP call
|
|
315
|
+
response = yield {
|
|
316
|
+
"method": "GET",
|
|
317
|
+
"url": Path("/api/git/diff") / self.working_dir / path,
|
|
318
|
+
"headers": self._headers,
|
|
319
|
+
"timeout": 60.0,
|
|
320
|
+
}
|
|
321
|
+
response.raise_for_status()
|
|
322
|
+
diff = GitDiff.model_validate(response.json())
|
|
323
|
+
return diff
|