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,85 @@
1
+ from collections.abc import Mapping, Sequence
2
+
3
+ from pydantic import BaseModel
4
+
5
+
6
+ def _normalize(x):
7
+ # Convert Pydantic models to dicts
8
+ if isinstance(x, BaseModel):
9
+ return x.model_dump(exclude_none=True)
10
+ # Recurse mappings and sequences (but not strings/bytes)
11
+ if isinstance(x, Mapping):
12
+ return {k: _normalize(v) for k, v in x.items()}
13
+ if isinstance(x, Sequence) and not isinstance(x, (str, bytes, bytearray)):
14
+ return [_normalize(v) for v in x]
15
+ return x
16
+
17
+
18
+ def _structured_diff(a, b):
19
+ a = _normalize(a)
20
+ b = _normalize(b)
21
+
22
+ # Equal after normalization -> no diff
23
+ if a == b:
24
+ return {}
25
+
26
+ # Dict vs dict: diff by keys
27
+ if isinstance(a, Mapping) and isinstance(b, Mapping):
28
+ keys = set(a) | set(b)
29
+ out = {}
30
+ for k in sorted(keys, key=lambda x: (str(type(x)), str(x))):
31
+ ak = a.get(k, ...)
32
+ bk = b.get(k, ...)
33
+ if ak is ...:
34
+ out[k] = ("<missing>", bk)
35
+ elif bk is ...:
36
+ out[k] = (ak, "<missing>")
37
+ else:
38
+ sub = _structured_diff(ak, bk)
39
+ out[k] = sub if sub else (ak, bk) if ak != bk else {}
40
+ # Remove entries that ended up equal (empty dicts)
41
+ return {k: v for k, v in out.items() if v != {}}
42
+
43
+ # List/tuple vs list/tuple: diff by index
44
+ if (
45
+ isinstance(a, Sequence)
46
+ and isinstance(b, Sequence)
47
+ and not isinstance(a, (str, bytes, bytearray))
48
+ and not isinstance(b, (str, bytes, bytearray))
49
+ ):
50
+ out = {}
51
+ n = max(len(a), len(b))
52
+ for i in range(n):
53
+ ai = a[i] if i < len(a) else ...
54
+ bi = b[i] if i < len(b) else ...
55
+ if ai is ...:
56
+ out[i] = ("<missing>", bi)
57
+ elif bi is ...:
58
+ out[i] = (ai, "<missing>")
59
+ else:
60
+ sub = _structured_diff(ai, bi)
61
+ out[i] = sub if sub else (ai, bi) if ai != bi else {}
62
+ return {k: v for k, v in out.items() if v != {}}
63
+
64
+ # Fallback leaf difference
65
+ return (a, b)
66
+
67
+
68
+ def _format_diff(d, indent=0):
69
+ if not isinstance(d, Mapping):
70
+ old, new = d
71
+ return f"{' ' * indent}{old!r} -> {new!r}"
72
+ lines = []
73
+ pad = " " * indent
74
+ for key, val in d.items():
75
+ if isinstance(val, Mapping):
76
+ lines.append(f"{pad}{key}:")
77
+ lines.append(_format_diff(val, indent + 1))
78
+ else:
79
+ lines.append(f"{pad}{key}: {_format_diff(val, indent + 1).lstrip()}")
80
+ return "\n".join(lines)
81
+
82
+
83
+ def pretty_pydantic_diff(a: BaseModel, b: BaseModel) -> str:
84
+ diff = _structured_diff(a, b)
85
+ return "No differences" if not diff else _format_diff(diff)
@@ -0,0 +1,64 @@
1
+ from pydantic import SecretStr
2
+
3
+ from openhands.sdk.utils.cipher import Cipher
4
+
5
+
6
+ def serialize_secret(v: SecretStr | None, info):
7
+ """
8
+ Serialize secret fields with encryption or redaction.
9
+
10
+ - If a cipher is provided in context, encrypts the secret value
11
+ - If expose_secrets flag is True in context, exposes the actual value
12
+ - Otherwise, lets Pydantic handle default masking (redaction)
13
+ - This prevents accidental secret disclosure
14
+ """ # noqa: E501
15
+ if v is None:
16
+ return None
17
+
18
+ # check if a cipher is supplied
19
+ if info.context and info.context.get("cipher"):
20
+ cipher: Cipher = info.context.get("cipher")
21
+ return cipher.encrypt(v)
22
+
23
+ # Check if the 'expose_secrets' flag is in the serialization context
24
+ if info.context and info.context.get("expose_secrets"):
25
+ return v.get_secret_value()
26
+
27
+ # Let Pydantic handle the default masking
28
+ return v
29
+
30
+
31
+ def validate_secret(v: str | SecretStr | None, info) -> SecretStr | None:
32
+ """
33
+ Deserialize secret fields, handling encryption and empty values.
34
+
35
+ Accepts both str and SecretStr inputs, always returns SecretStr | None.
36
+ - Empty secrets are converted to None
37
+ - Plain strings are converted to SecretStr
38
+ - If a cipher is provided in context, attempts to decrypt the value
39
+ - If decryption fails, the cipher returns None and a warning is logged
40
+ - This gracefully handles conversations encrypted with different keys or were redacted
41
+ """ # noqa: E501
42
+ if v is None:
43
+ return None
44
+
45
+ # Handle both SecretStr and string inputs
46
+ if isinstance(v, SecretStr):
47
+ secret_value = v.get_secret_value()
48
+ else:
49
+ secret_value = v
50
+
51
+ # If the secret is empty, whitespace-only or redacted - return None
52
+ if not secret_value or not secret_value.strip() or secret_value == "**********":
53
+ return None
54
+
55
+ # check if a cipher is supplied
56
+ if info.context and info.context.get("cipher"):
57
+ cipher: Cipher = info.context.get("cipher")
58
+ return cipher.decrypt(secret_value)
59
+
60
+ # Always return SecretStr
61
+ if isinstance(v, SecretStr):
62
+ return v
63
+ else:
64
+ return SecretStr(secret_value)
@@ -0,0 +1,117 @@
1
+ """Utility functions for truncating text content."""
2
+
3
+ import hashlib
4
+ from pathlib import Path
5
+
6
+ from openhands.sdk.logger import get_logger
7
+
8
+
9
+ logger = get_logger(__name__)
10
+
11
+ # Default truncation limits
12
+ DEFAULT_TEXT_CONTENT_LIMIT = 50_000
13
+
14
+ # Default truncation notice
15
+ DEFAULT_TRUNCATE_NOTICE = (
16
+ "<response clipped><NOTE>Due to the max output limit, only part of the full "
17
+ "response has been shown to you.</NOTE>"
18
+ ) # 113 chars
19
+
20
+ DEFAULT_TRUNCATE_NOTICE_WITH_PERSIST = (
21
+ "<response clipped><NOTE>Due to the max output limit, only part of the full "
22
+ "response has been shown to you. The complete output has been saved to "
23
+ "{file_path} - you can use other tools to view the full content (truncated "
24
+ "part starts around line {line_num}).</NOTE>"
25
+ )
26
+
27
+
28
+ def _save_full_content(content: str, save_dir: str, tool_prefix: str) -> str | None:
29
+ """Save full content to the specified directory and return the file path."""
30
+
31
+ save_dir_path = Path(save_dir)
32
+ save_dir_path.mkdir(parents=True, exist_ok=True)
33
+
34
+ # Generate hash-based filename for deduplication
35
+ content_hash = hashlib.sha256(content.encode("utf-8")).hexdigest()[:8]
36
+ filename = f"{tool_prefix}_output_{content_hash}.txt"
37
+ file_path = save_dir_path / filename
38
+
39
+ # Only write if file doesn't exist (deduplication)
40
+ if not file_path.exists():
41
+ try:
42
+ file_path.write_text(content, encoding="utf-8")
43
+ except Exception as e:
44
+ logger.debug(f"Failed to save full content to {file_path}: {e}")
45
+ return None
46
+
47
+ return str(file_path)
48
+
49
+
50
+ def maybe_truncate(
51
+ content: str,
52
+ truncate_after: int | None = None,
53
+ truncate_notice: str = DEFAULT_TRUNCATE_NOTICE,
54
+ save_dir: str | None = None,
55
+ tool_prefix: str = "output",
56
+ ) -> str:
57
+ """
58
+ Truncate the middle of content if it exceeds the specified length.
59
+
60
+ Keeps the head and tail of the content to preserve context at both ends.
61
+ Optionally saves the full content to a file for later investigation.
62
+
63
+ Args:
64
+ content: The text content to potentially truncate
65
+ truncate_after: Maximum length before truncation. If None, no truncation occurs
66
+ truncate_notice: Notice to insert in the middle when content is truncated
67
+ save_dir: Working directory to save full content file in
68
+ tool_prefix: Prefix for the saved file (e.g., "bash", "browser", "editor")
69
+
70
+ Returns:
71
+ Original content if under limit, or truncated content with head and tail
72
+ preserved and reference to saved file if applicable
73
+ """
74
+ # 1) Early exits: no truncation requested, or content already within limit
75
+ if not truncate_after or len(content) <= truncate_after or truncate_after < 0:
76
+ return content
77
+
78
+ # 2) If even the base notice doesn't fit, return a slice of it
79
+ if len(truncate_notice) >= truncate_after:
80
+ return truncate_notice[:truncate_after]
81
+
82
+ # 3) Calculate proposed head size based on base notice
83
+ # (for consistent line number calc)
84
+ available_chars = truncate_after - len(truncate_notice)
85
+ # Prefer giving the "extra" char to head (ceil split)
86
+ proposed_head = available_chars // 2 + (available_chars % 2)
87
+
88
+ # 4) Optionally save full content, then construct the final notice
89
+ final_notice = truncate_notice
90
+ if save_dir:
91
+ saved_file_path = _save_full_content(content, save_dir, tool_prefix)
92
+ if saved_file_path:
93
+ # Calculate line number where truncation happens (using head_chars)
94
+ head_content_lines = len(content[:proposed_head].splitlines())
95
+
96
+ final_notice = DEFAULT_TRUNCATE_NOTICE_WITH_PERSIST.format(
97
+ file_path=saved_file_path,
98
+ line_num=head_content_lines + 1, # +1 to indicate next line
99
+ )
100
+
101
+ # 5) If the final notice (with persist info) alone fills the
102
+ # budget, return a slice of it
103
+ if len(final_notice) >= truncate_after:
104
+ return final_notice[:truncate_after]
105
+
106
+ # 6) Allocate remaining budget to head/tail
107
+ remaining = truncate_after - len(final_notice)
108
+ head_chars = min(
109
+ proposed_head, remaining
110
+ ) # Ensure head_chars doesn't exceed remaining
111
+ tail_chars = remaining - head_chars # non-negative due to previous checks
112
+
113
+ return (
114
+ content[:head_chars]
115
+ + final_notice
116
+ + (content[-tail_chars:] if tail_chars > 0 else "")
117
+ )
@@ -0,0 +1,58 @@
1
+ from rich.text import Text
2
+
3
+
4
+ def display_dict(d) -> Text:
5
+ """Create a Rich Text representation of a dictionary.
6
+
7
+ This function is deprecated. Use display_json instead.
8
+ """
9
+ return display_json(d)
10
+
11
+
12
+ def display_json(data) -> Text:
13
+ """Create a Rich Text representation of JSON data.
14
+
15
+ Handles dictionaries, lists, strings, numbers, booleans, and None values.
16
+ """
17
+ content = Text()
18
+
19
+ if isinstance(data, dict):
20
+ for field_name, field_value in data.items():
21
+ if field_value is None:
22
+ continue # skip None fields
23
+ content.append(f"\n {field_name}: ", style="bold")
24
+ if isinstance(field_value, str):
25
+ # Handle multiline strings with proper indentation
26
+ if "\n" in field_value:
27
+ content.append("\n")
28
+ for line in field_value.split("\n"):
29
+ content.append(f" {line}\n")
30
+ else:
31
+ content.append(f'"{field_value}"')
32
+ elif isinstance(field_value, (list, dict)):
33
+ content.append(str(field_value))
34
+ else:
35
+ content.append(str(field_value))
36
+ elif isinstance(data, list):
37
+ content.append(f"[List with {len(data)} items]\n")
38
+ for i, item in enumerate(data):
39
+ content.append(f" [{i}]: ", style="bold")
40
+ if isinstance(item, str):
41
+ content.append(f'"{item}"\n')
42
+ else:
43
+ content.append(f"{item}\n")
44
+ elif isinstance(data, str):
45
+ # Handle multiline strings with proper indentation
46
+ if "\n" in data:
47
+ content.append("String:\n")
48
+ for line in data.split("\n"):
49
+ content.append(f" {line}\n")
50
+ else:
51
+ content.append(f'"{data}"')
52
+ elif data is None:
53
+ content.append("null")
54
+ else:
55
+ # Handle numbers, booleans, and other JSON primitives
56
+ content.append(str(data))
57
+
58
+ return content
@@ -0,0 +1,17 @@
1
+ from .base import BaseWorkspace
2
+ from .local import LocalWorkspace
3
+ from .models import CommandResult, FileOperationResult, PlatformType, TargetType
4
+ from .remote import RemoteWorkspace
5
+ from .workspace import Workspace
6
+
7
+
8
+ __all__ = [
9
+ "BaseWorkspace",
10
+ "CommandResult",
11
+ "FileOperationResult",
12
+ "LocalWorkspace",
13
+ "PlatformType",
14
+ "RemoteWorkspace",
15
+ "TargetType",
16
+ "Workspace",
17
+ ]
@@ -0,0 +1,158 @@
1
+ from abc import ABC, abstractmethod
2
+ from pathlib import Path
3
+ from typing import Annotated, Any
4
+
5
+ from pydantic import BeforeValidator, Field
6
+
7
+ from openhands.sdk.git.models import GitChange, GitDiff
8
+ from openhands.sdk.logger import get_logger
9
+ from openhands.sdk.utils.models import DiscriminatedUnionMixin
10
+ from openhands.sdk.workspace.models import CommandResult, FileOperationResult
11
+
12
+
13
+ logger = get_logger(__name__)
14
+
15
+
16
+ def _convert_path_to_str(v: str | Path) -> str:
17
+ """Convert Path objects to string for working_dir."""
18
+ if isinstance(v, Path):
19
+ return str(v)
20
+ return v
21
+
22
+
23
+ class BaseWorkspace(DiscriminatedUnionMixin, ABC):
24
+ """Abstract base class for workspace implementations.
25
+
26
+ Workspaces provide a sandboxed environment where agents can execute commands,
27
+ read/write files, and perform other operations. All workspace implementations
28
+ support the context manager protocol for safe resource management.
29
+
30
+ Example:
31
+ >>> with workspace:
32
+ ... result = workspace.execute_command("echo 'hello'")
33
+ ... content = workspace.read_file("example.txt")
34
+ """
35
+
36
+ working_dir: Annotated[
37
+ str,
38
+ BeforeValidator(_convert_path_to_str),
39
+ Field(
40
+ description=(
41
+ "The working directory for agent operations and tool execution. "
42
+ "Accepts both string paths and Path objects. "
43
+ "Path objects are automatically converted to strings."
44
+ )
45
+ ),
46
+ ]
47
+
48
+ def __enter__(self) -> "BaseWorkspace":
49
+ """Enter the workspace context.
50
+
51
+ Returns:
52
+ Self for use in with statements
53
+ """
54
+ return self
55
+
56
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
57
+ """Exit the workspace context and cleanup resources.
58
+
59
+ Default implementation performs no cleanup. Subclasses should override
60
+ to add cleanup logic (e.g., stopping containers, closing connections).
61
+
62
+ Args:
63
+ exc_type: Exception type if an exception occurred
64
+ exc_val: Exception value if an exception occurred
65
+ exc_tb: Exception traceback if an exception occurred
66
+ """
67
+ pass
68
+
69
+ @abstractmethod
70
+ def execute_command(
71
+ self,
72
+ command: str,
73
+ cwd: str | Path | None = None,
74
+ timeout: float = 30.0,
75
+ ) -> CommandResult:
76
+ """Execute a bash command on the system.
77
+
78
+ Args:
79
+ command: The bash command to execute
80
+ cwd: Working directory for the command (optional)
81
+ timeout: Timeout in seconds (defaults to 30.0)
82
+
83
+ Returns:
84
+ CommandResult: Result containing stdout, stderr, exit_code, and other
85
+ metadata
86
+
87
+ Raises:
88
+ Exception: If command execution fails
89
+ """
90
+ ...
91
+
92
+ @abstractmethod
93
+ def file_upload(
94
+ self,
95
+ source_path: str | Path,
96
+ destination_path: str | Path,
97
+ ) -> FileOperationResult:
98
+ """Upload a file to the system.
99
+
100
+ Args:
101
+ source_path: Path to the source file
102
+ destination_path: Path where the file should be uploaded
103
+
104
+ Returns:
105
+ FileOperationResult: Result containing success status and metadata
106
+
107
+ Raises:
108
+ Exception: If file upload fails
109
+ """
110
+ ...
111
+
112
+ @abstractmethod
113
+ def file_download(
114
+ self,
115
+ source_path: str | Path,
116
+ destination_path: str | Path,
117
+ ) -> FileOperationResult:
118
+ """Download a file from the system.
119
+
120
+ Args:
121
+ source_path: Path to the source file on the system
122
+ destination_path: Path where the file should be downloaded
123
+
124
+ Returns:
125
+ FileOperationResult: Result containing success status and metadata
126
+
127
+ Raises:
128
+ Exception: If file download fails
129
+ """
130
+ ...
131
+
132
+ @abstractmethod
133
+ def git_changes(self, path: str | Path) -> list[GitChange]:
134
+ """Get the git changes for the repository at the path given.
135
+
136
+ Args:
137
+ path: Path to the git repository
138
+
139
+ Returns:
140
+ list[GitChange]: List of changes
141
+
142
+ Raises:
143
+ Exception: If path is not a git repository or getting changes failed
144
+ """
145
+
146
+ @abstractmethod
147
+ def git_diff(self, path: str | Path) -> GitDiff:
148
+ """Get the git diff for the file at the path given.
149
+
150
+ Args:
151
+ path: Path to the file
152
+
153
+ Returns:
154
+ GitDiff: Git diff
155
+
156
+ Raises:
157
+ Exception: If path is not a git repository or getting diff failed
158
+ """
@@ -0,0 +1,189 @@
1
+ import shutil
2
+ from pathlib import Path
3
+ from typing import Any
4
+
5
+ from openhands.sdk.git.git_changes import get_git_changes
6
+ from openhands.sdk.git.git_diff import get_git_diff
7
+ from openhands.sdk.git.models import GitChange, GitDiff
8
+ from openhands.sdk.logger import get_logger
9
+ from openhands.sdk.utils.command import execute_command
10
+ from openhands.sdk.workspace.base import BaseWorkspace
11
+ from openhands.sdk.workspace.models import CommandResult, FileOperationResult
12
+
13
+
14
+ logger = get_logger(__name__)
15
+
16
+
17
+ class LocalWorkspace(BaseWorkspace):
18
+ """Local workspace implementation that operates on the host filesystem.
19
+
20
+ LocalWorkspace provides direct access to the local filesystem and command execution
21
+ environment. It's suitable for development and testing scenarios where the agent
22
+ should operate directly on the host system.
23
+
24
+ Example:
25
+ >>> workspace = LocalWorkspace(working_dir="/path/to/project")
26
+ >>> with workspace:
27
+ ... result = workspace.execute_command("ls -la")
28
+ ... content = workspace.read_file("README.md")
29
+ """
30
+
31
+ def __init__(self, *, working_dir: str | Path, **kwargs: Any):
32
+ # Accept Path in signature for ergonomics and type checkers,
33
+ # but normalize to str for the underlying model field.
34
+ super().__init__(working_dir=str(working_dir), **kwargs)
35
+
36
+ def execute_command(
37
+ self,
38
+ command: str,
39
+ cwd: str | Path | None = None,
40
+ timeout: float = 30.0,
41
+ ) -> CommandResult:
42
+ """Execute a bash command locally.
43
+
44
+ Uses the shared shell execution utility to run commands with proper
45
+ timeout handling, output streaming, and error management.
46
+
47
+ Args:
48
+ command: The bash command to execute
49
+ cwd: Working directory (optional)
50
+ timeout: Timeout in seconds
51
+
52
+ Returns:
53
+ CommandResult: Result with stdout, stderr, exit_code, command, and
54
+ timeout_occurred
55
+ """
56
+ logger.debug(f"Executing local bash command: {command} in {cwd}")
57
+ result = execute_command(
58
+ command,
59
+ cwd=str(cwd) if cwd is not None else str(self.working_dir),
60
+ timeout=timeout,
61
+ print_output=True,
62
+ )
63
+ return CommandResult(
64
+ command=command,
65
+ exit_code=result.returncode,
66
+ stdout=result.stdout,
67
+ stderr=result.stderr,
68
+ timeout_occurred=result.returncode == -1,
69
+ )
70
+
71
+ def file_upload(
72
+ self,
73
+ source_path: str | Path,
74
+ destination_path: str | Path,
75
+ ) -> FileOperationResult:
76
+ """Upload (copy) a file locally.
77
+
78
+ For local systems, file upload is implemented as a file copy operation
79
+ using shutil.copy2 to preserve metadata.
80
+
81
+ Args:
82
+ source_path: Path to the source file
83
+ destination_path: Path where the file should be copied
84
+
85
+ Returns:
86
+ FileOperationResult: Result with success status and file information
87
+ """
88
+ source = Path(source_path)
89
+ destination = Path(destination_path)
90
+
91
+ logger.debug(f"Local file upload: {source} -> {destination}")
92
+
93
+ try:
94
+ # Ensure destination directory exists
95
+ destination.parent.mkdir(parents=True, exist_ok=True)
96
+
97
+ # Copy the file with metadata preservation
98
+ shutil.copy2(source, destination)
99
+
100
+ return FileOperationResult(
101
+ success=True,
102
+ source_path=str(source),
103
+ destination_path=str(destination),
104
+ file_size=destination.stat().st_size,
105
+ )
106
+
107
+ except Exception as e:
108
+ logger.error(f"Local file upload failed: {e}")
109
+ return FileOperationResult(
110
+ success=False,
111
+ source_path=str(source),
112
+ destination_path=str(destination),
113
+ error=str(e),
114
+ )
115
+
116
+ def file_download(
117
+ self,
118
+ source_path: str | Path,
119
+ destination_path: str | Path,
120
+ ) -> FileOperationResult:
121
+ """Download (copy) a file locally.
122
+
123
+ For local systems, file download is implemented as a file copy operation
124
+ using shutil.copy2 to preserve metadata.
125
+
126
+ Args:
127
+ source_path: Path to the source file
128
+ destination_path: Path where the file should be copied
129
+
130
+ Returns:
131
+ FileOperationResult: Result with success status and file information
132
+ """
133
+ source = Path(source_path)
134
+ destination = Path(destination_path)
135
+
136
+ logger.debug(f"Local file download: {source} -> {destination}")
137
+
138
+ try:
139
+ # Ensure destination directory exists
140
+ destination.parent.mkdir(parents=True, exist_ok=True)
141
+
142
+ # Copy the file with metadata preservation
143
+ shutil.copy2(source, destination)
144
+
145
+ return FileOperationResult(
146
+ success=True,
147
+ source_path=str(source),
148
+ destination_path=str(destination),
149
+ file_size=destination.stat().st_size,
150
+ )
151
+
152
+ except Exception as e:
153
+ logger.error(f"Local file download failed: {e}")
154
+ return FileOperationResult(
155
+ success=False,
156
+ source_path=str(source),
157
+ destination_path=str(destination),
158
+ error=str(e),
159
+ )
160
+
161
+ def git_changes(self, path: str | Path) -> list[GitChange]:
162
+ """Get the git changes for the repository at the path given.
163
+
164
+ Args:
165
+ path: Path to the git repository
166
+
167
+ Returns:
168
+ list[GitChange]: List of changes
169
+
170
+ Raises:
171
+ Exception: If path is not a git repository or getting changes failed
172
+ """
173
+ path = Path(self.working_dir) / path
174
+ return get_git_changes(path)
175
+
176
+ def git_diff(self, path: str | Path) -> GitDiff:
177
+ """Get the git diff for the file at the path given.
178
+
179
+ Args:
180
+ path: Path to the file
181
+
182
+ Returns:
183
+ GitDiff: Git diff
184
+
185
+ Raises:
186
+ Exception: If path is not a git repository or getting diff failed
187
+ """
188
+ path = Path(self.working_dir) / path
189
+ return get_git_diff(path)