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.
Files changed (180) hide show
  1. openhands/sdk/__init__.py +111 -0
  2. openhands/sdk/agent/__init__.py +8 -0
  3. openhands/sdk/agent/agent.py +650 -0
  4. openhands/sdk/agent/base.py +457 -0
  5. openhands/sdk/agent/prompts/in_context_learning_example.j2 +169 -0
  6. openhands/sdk/agent/prompts/in_context_learning_example_suffix.j2 +3 -0
  7. openhands/sdk/agent/prompts/model_specific/anthropic_claude.j2 +3 -0
  8. openhands/sdk/agent/prompts/model_specific/google_gemini.j2 +1 -0
  9. openhands/sdk/agent/prompts/model_specific/openai_gpt/gpt-5-codex.j2 +2 -0
  10. openhands/sdk/agent/prompts/model_specific/openai_gpt/gpt-5.j2 +3 -0
  11. openhands/sdk/agent/prompts/security_policy.j2 +22 -0
  12. openhands/sdk/agent/prompts/security_risk_assessment.j2 +21 -0
  13. openhands/sdk/agent/prompts/self_documentation.j2 +15 -0
  14. openhands/sdk/agent/prompts/system_prompt.j2 +132 -0
  15. openhands/sdk/agent/prompts/system_prompt_interactive.j2 +14 -0
  16. openhands/sdk/agent/prompts/system_prompt_long_horizon.j2 +40 -0
  17. openhands/sdk/agent/prompts/system_prompt_planning.j2 +40 -0
  18. openhands/sdk/agent/prompts/system_prompt_tech_philosophy.j2 +122 -0
  19. openhands/sdk/agent/utils.py +228 -0
  20. openhands/sdk/context/__init__.py +28 -0
  21. openhands/sdk/context/agent_context.py +264 -0
  22. openhands/sdk/context/condenser/__init__.py +18 -0
  23. openhands/sdk/context/condenser/base.py +100 -0
  24. openhands/sdk/context/condenser/llm_summarizing_condenser.py +248 -0
  25. openhands/sdk/context/condenser/no_op_condenser.py +14 -0
  26. openhands/sdk/context/condenser/pipeline_condenser.py +56 -0
  27. openhands/sdk/context/condenser/prompts/summarizing_prompt.j2 +59 -0
  28. openhands/sdk/context/condenser/utils.py +149 -0
  29. openhands/sdk/context/prompts/__init__.py +6 -0
  30. openhands/sdk/context/prompts/prompt.py +114 -0
  31. openhands/sdk/context/prompts/templates/ask_agent_template.j2 +11 -0
  32. openhands/sdk/context/prompts/templates/skill_knowledge_info.j2 +8 -0
  33. openhands/sdk/context/prompts/templates/system_message_suffix.j2 +32 -0
  34. openhands/sdk/context/skills/__init__.py +28 -0
  35. openhands/sdk/context/skills/exceptions.py +11 -0
  36. openhands/sdk/context/skills/skill.py +720 -0
  37. openhands/sdk/context/skills/trigger.py +36 -0
  38. openhands/sdk/context/skills/types.py +48 -0
  39. openhands/sdk/context/view.py +503 -0
  40. openhands/sdk/conversation/__init__.py +40 -0
  41. openhands/sdk/conversation/base.py +281 -0
  42. openhands/sdk/conversation/conversation.py +152 -0
  43. openhands/sdk/conversation/conversation_stats.py +85 -0
  44. openhands/sdk/conversation/event_store.py +157 -0
  45. openhands/sdk/conversation/events_list_base.py +17 -0
  46. openhands/sdk/conversation/exceptions.py +50 -0
  47. openhands/sdk/conversation/fifo_lock.py +133 -0
  48. openhands/sdk/conversation/impl/__init__.py +5 -0
  49. openhands/sdk/conversation/impl/local_conversation.py +665 -0
  50. openhands/sdk/conversation/impl/remote_conversation.py +956 -0
  51. openhands/sdk/conversation/persistence_const.py +9 -0
  52. openhands/sdk/conversation/response_utils.py +41 -0
  53. openhands/sdk/conversation/secret_registry.py +126 -0
  54. openhands/sdk/conversation/serialization_diff.py +0 -0
  55. openhands/sdk/conversation/state.py +392 -0
  56. openhands/sdk/conversation/stuck_detector.py +311 -0
  57. openhands/sdk/conversation/title_utils.py +191 -0
  58. openhands/sdk/conversation/types.py +45 -0
  59. openhands/sdk/conversation/visualizer/__init__.py +12 -0
  60. openhands/sdk/conversation/visualizer/base.py +67 -0
  61. openhands/sdk/conversation/visualizer/default.py +373 -0
  62. openhands/sdk/critic/__init__.py +15 -0
  63. openhands/sdk/critic/base.py +38 -0
  64. openhands/sdk/critic/impl/__init__.py +12 -0
  65. openhands/sdk/critic/impl/agent_finished.py +83 -0
  66. openhands/sdk/critic/impl/empty_patch.py +49 -0
  67. openhands/sdk/critic/impl/pass_critic.py +42 -0
  68. openhands/sdk/event/__init__.py +42 -0
  69. openhands/sdk/event/base.py +149 -0
  70. openhands/sdk/event/condenser.py +82 -0
  71. openhands/sdk/event/conversation_error.py +25 -0
  72. openhands/sdk/event/conversation_state.py +104 -0
  73. openhands/sdk/event/llm_completion_log.py +39 -0
  74. openhands/sdk/event/llm_convertible/__init__.py +20 -0
  75. openhands/sdk/event/llm_convertible/action.py +139 -0
  76. openhands/sdk/event/llm_convertible/message.py +142 -0
  77. openhands/sdk/event/llm_convertible/observation.py +141 -0
  78. openhands/sdk/event/llm_convertible/system.py +61 -0
  79. openhands/sdk/event/token.py +16 -0
  80. openhands/sdk/event/types.py +11 -0
  81. openhands/sdk/event/user_action.py +21 -0
  82. openhands/sdk/git/exceptions.py +43 -0
  83. openhands/sdk/git/git_changes.py +249 -0
  84. openhands/sdk/git/git_diff.py +129 -0
  85. openhands/sdk/git/models.py +21 -0
  86. openhands/sdk/git/utils.py +189 -0
  87. openhands/sdk/hooks/__init__.py +30 -0
  88. openhands/sdk/hooks/config.py +180 -0
  89. openhands/sdk/hooks/conversation_hooks.py +227 -0
  90. openhands/sdk/hooks/executor.py +155 -0
  91. openhands/sdk/hooks/manager.py +170 -0
  92. openhands/sdk/hooks/types.py +40 -0
  93. openhands/sdk/io/__init__.py +6 -0
  94. openhands/sdk/io/base.py +48 -0
  95. openhands/sdk/io/cache.py +85 -0
  96. openhands/sdk/io/local.py +119 -0
  97. openhands/sdk/io/memory.py +54 -0
  98. openhands/sdk/llm/__init__.py +45 -0
  99. openhands/sdk/llm/exceptions/__init__.py +45 -0
  100. openhands/sdk/llm/exceptions/classifier.py +50 -0
  101. openhands/sdk/llm/exceptions/mapping.py +54 -0
  102. openhands/sdk/llm/exceptions/types.py +101 -0
  103. openhands/sdk/llm/llm.py +1140 -0
  104. openhands/sdk/llm/llm_registry.py +122 -0
  105. openhands/sdk/llm/llm_response.py +59 -0
  106. openhands/sdk/llm/message.py +656 -0
  107. openhands/sdk/llm/mixins/fn_call_converter.py +1288 -0
  108. openhands/sdk/llm/mixins/non_native_fc.py +97 -0
  109. openhands/sdk/llm/options/__init__.py +1 -0
  110. openhands/sdk/llm/options/chat_options.py +93 -0
  111. openhands/sdk/llm/options/common.py +19 -0
  112. openhands/sdk/llm/options/responses_options.py +67 -0
  113. openhands/sdk/llm/router/__init__.py +10 -0
  114. openhands/sdk/llm/router/base.py +117 -0
  115. openhands/sdk/llm/router/impl/multimodal.py +76 -0
  116. openhands/sdk/llm/router/impl/random.py +22 -0
  117. openhands/sdk/llm/streaming.py +9 -0
  118. openhands/sdk/llm/utils/metrics.py +312 -0
  119. openhands/sdk/llm/utils/model_features.py +192 -0
  120. openhands/sdk/llm/utils/model_info.py +90 -0
  121. openhands/sdk/llm/utils/model_prompt_spec.py +98 -0
  122. openhands/sdk/llm/utils/retry_mixin.py +128 -0
  123. openhands/sdk/llm/utils/telemetry.py +362 -0
  124. openhands/sdk/llm/utils/unverified_models.py +156 -0
  125. openhands/sdk/llm/utils/verified_models.py +65 -0
  126. openhands/sdk/logger/__init__.py +22 -0
  127. openhands/sdk/logger/logger.py +195 -0
  128. openhands/sdk/logger/rolling.py +113 -0
  129. openhands/sdk/mcp/__init__.py +24 -0
  130. openhands/sdk/mcp/client.py +76 -0
  131. openhands/sdk/mcp/definition.py +106 -0
  132. openhands/sdk/mcp/exceptions.py +19 -0
  133. openhands/sdk/mcp/tool.py +270 -0
  134. openhands/sdk/mcp/utils.py +83 -0
  135. openhands/sdk/observability/__init__.py +4 -0
  136. openhands/sdk/observability/laminar.py +166 -0
  137. openhands/sdk/observability/utils.py +20 -0
  138. openhands/sdk/py.typed +0 -0
  139. openhands/sdk/secret/__init__.py +19 -0
  140. openhands/sdk/secret/secrets.py +92 -0
  141. openhands/sdk/security/__init__.py +6 -0
  142. openhands/sdk/security/analyzer.py +111 -0
  143. openhands/sdk/security/confirmation_policy.py +61 -0
  144. openhands/sdk/security/llm_analyzer.py +29 -0
  145. openhands/sdk/security/risk.py +100 -0
  146. openhands/sdk/tool/__init__.py +34 -0
  147. openhands/sdk/tool/builtins/__init__.py +34 -0
  148. openhands/sdk/tool/builtins/finish.py +106 -0
  149. openhands/sdk/tool/builtins/think.py +117 -0
  150. openhands/sdk/tool/registry.py +184 -0
  151. openhands/sdk/tool/schema.py +286 -0
  152. openhands/sdk/tool/spec.py +39 -0
  153. openhands/sdk/tool/tool.py +481 -0
  154. openhands/sdk/utils/__init__.py +22 -0
  155. openhands/sdk/utils/async_executor.py +115 -0
  156. openhands/sdk/utils/async_utils.py +39 -0
  157. openhands/sdk/utils/cipher.py +68 -0
  158. openhands/sdk/utils/command.py +90 -0
  159. openhands/sdk/utils/deprecation.py +166 -0
  160. openhands/sdk/utils/github.py +44 -0
  161. openhands/sdk/utils/json.py +48 -0
  162. openhands/sdk/utils/models.py +570 -0
  163. openhands/sdk/utils/paging.py +63 -0
  164. openhands/sdk/utils/pydantic_diff.py +85 -0
  165. openhands/sdk/utils/pydantic_secrets.py +64 -0
  166. openhands/sdk/utils/truncate.py +117 -0
  167. openhands/sdk/utils/visualize.py +58 -0
  168. openhands/sdk/workspace/__init__.py +17 -0
  169. openhands/sdk/workspace/base.py +158 -0
  170. openhands/sdk/workspace/local.py +189 -0
  171. openhands/sdk/workspace/models.py +35 -0
  172. openhands/sdk/workspace/remote/__init__.py +8 -0
  173. openhands/sdk/workspace/remote/async_remote_workspace.py +149 -0
  174. openhands/sdk/workspace/remote/base.py +164 -0
  175. openhands/sdk/workspace/remote/remote_workspace_mixin.py +323 -0
  176. openhands/sdk/workspace/workspace.py +49 -0
  177. openhands_sdk-1.7.3.dist-info/METADATA +17 -0
  178. openhands_sdk-1.7.3.dist-info/RECORD +180 -0
  179. openhands_sdk-1.7.3.dist-info/WHEEL +5 -0
  180. 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,8 @@
1
+ """Remote workspace implementations."""
2
+
3
+ from .base import RemoteWorkspace
4
+
5
+
6
+ __all__ = [
7
+ "RemoteWorkspace",
8
+ ]
@@ -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