openhands-sdk 1.7.0__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 (172) hide show
  1. openhands/sdk/__init__.py +111 -0
  2. openhands/sdk/agent/__init__.py +8 -0
  3. openhands/sdk/agent/agent.py +607 -0
  4. openhands/sdk/agent/base.py +454 -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 +3 -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 +223 -0
  20. openhands/sdk/context/__init__.py +28 -0
  21. openhands/sdk/context/agent_context.py +240 -0
  22. openhands/sdk/context/condenser/__init__.py +18 -0
  23. openhands/sdk/context/condenser/base.py +95 -0
  24. openhands/sdk/context/condenser/llm_summarizing_condenser.py +89 -0
  25. openhands/sdk/context/condenser/no_op_condenser.py +13 -0
  26. openhands/sdk/context/condenser/pipeline_condenser.py +55 -0
  27. openhands/sdk/context/condenser/prompts/summarizing_prompt.j2 +59 -0
  28. openhands/sdk/context/prompts/__init__.py +6 -0
  29. openhands/sdk/context/prompts/prompt.py +114 -0
  30. openhands/sdk/context/prompts/templates/ask_agent_template.j2 +11 -0
  31. openhands/sdk/context/prompts/templates/skill_knowledge_info.j2 +8 -0
  32. openhands/sdk/context/prompts/templates/system_message_suffix.j2 +32 -0
  33. openhands/sdk/context/skills/__init__.py +28 -0
  34. openhands/sdk/context/skills/exceptions.py +11 -0
  35. openhands/sdk/context/skills/skill.py +630 -0
  36. openhands/sdk/context/skills/trigger.py +36 -0
  37. openhands/sdk/context/skills/types.py +48 -0
  38. openhands/sdk/context/view.py +306 -0
  39. openhands/sdk/conversation/__init__.py +40 -0
  40. openhands/sdk/conversation/base.py +281 -0
  41. openhands/sdk/conversation/conversation.py +146 -0
  42. openhands/sdk/conversation/conversation_stats.py +85 -0
  43. openhands/sdk/conversation/event_store.py +157 -0
  44. openhands/sdk/conversation/events_list_base.py +17 -0
  45. openhands/sdk/conversation/exceptions.py +50 -0
  46. openhands/sdk/conversation/fifo_lock.py +133 -0
  47. openhands/sdk/conversation/impl/__init__.py +5 -0
  48. openhands/sdk/conversation/impl/local_conversation.py +620 -0
  49. openhands/sdk/conversation/impl/remote_conversation.py +883 -0
  50. openhands/sdk/conversation/persistence_const.py +9 -0
  51. openhands/sdk/conversation/response_utils.py +41 -0
  52. openhands/sdk/conversation/secret_registry.py +126 -0
  53. openhands/sdk/conversation/serialization_diff.py +0 -0
  54. openhands/sdk/conversation/state.py +352 -0
  55. openhands/sdk/conversation/stuck_detector.py +311 -0
  56. openhands/sdk/conversation/title_utils.py +191 -0
  57. openhands/sdk/conversation/types.py +45 -0
  58. openhands/sdk/conversation/visualizer/__init__.py +12 -0
  59. openhands/sdk/conversation/visualizer/base.py +67 -0
  60. openhands/sdk/conversation/visualizer/default.py +373 -0
  61. openhands/sdk/critic/__init__.py +15 -0
  62. openhands/sdk/critic/base.py +38 -0
  63. openhands/sdk/critic/impl/__init__.py +12 -0
  64. openhands/sdk/critic/impl/agent_finished.py +83 -0
  65. openhands/sdk/critic/impl/empty_patch.py +49 -0
  66. openhands/sdk/critic/impl/pass_critic.py +42 -0
  67. openhands/sdk/event/__init__.py +42 -0
  68. openhands/sdk/event/base.py +149 -0
  69. openhands/sdk/event/condenser.py +82 -0
  70. openhands/sdk/event/conversation_error.py +25 -0
  71. openhands/sdk/event/conversation_state.py +104 -0
  72. openhands/sdk/event/llm_completion_log.py +39 -0
  73. openhands/sdk/event/llm_convertible/__init__.py +20 -0
  74. openhands/sdk/event/llm_convertible/action.py +139 -0
  75. openhands/sdk/event/llm_convertible/message.py +142 -0
  76. openhands/sdk/event/llm_convertible/observation.py +141 -0
  77. openhands/sdk/event/llm_convertible/system.py +61 -0
  78. openhands/sdk/event/token.py +16 -0
  79. openhands/sdk/event/types.py +11 -0
  80. openhands/sdk/event/user_action.py +21 -0
  81. openhands/sdk/git/exceptions.py +43 -0
  82. openhands/sdk/git/git_changes.py +249 -0
  83. openhands/sdk/git/git_diff.py +129 -0
  84. openhands/sdk/git/models.py +21 -0
  85. openhands/sdk/git/utils.py +189 -0
  86. openhands/sdk/io/__init__.py +6 -0
  87. openhands/sdk/io/base.py +48 -0
  88. openhands/sdk/io/local.py +82 -0
  89. openhands/sdk/io/memory.py +54 -0
  90. openhands/sdk/llm/__init__.py +45 -0
  91. openhands/sdk/llm/exceptions/__init__.py +45 -0
  92. openhands/sdk/llm/exceptions/classifier.py +50 -0
  93. openhands/sdk/llm/exceptions/mapping.py +54 -0
  94. openhands/sdk/llm/exceptions/types.py +101 -0
  95. openhands/sdk/llm/llm.py +1140 -0
  96. openhands/sdk/llm/llm_registry.py +122 -0
  97. openhands/sdk/llm/llm_response.py +59 -0
  98. openhands/sdk/llm/message.py +656 -0
  99. openhands/sdk/llm/mixins/fn_call_converter.py +1243 -0
  100. openhands/sdk/llm/mixins/non_native_fc.py +93 -0
  101. openhands/sdk/llm/options/__init__.py +1 -0
  102. openhands/sdk/llm/options/chat_options.py +93 -0
  103. openhands/sdk/llm/options/common.py +19 -0
  104. openhands/sdk/llm/options/responses_options.py +67 -0
  105. openhands/sdk/llm/router/__init__.py +10 -0
  106. openhands/sdk/llm/router/base.py +117 -0
  107. openhands/sdk/llm/router/impl/multimodal.py +76 -0
  108. openhands/sdk/llm/router/impl/random.py +22 -0
  109. openhands/sdk/llm/streaming.py +9 -0
  110. openhands/sdk/llm/utils/metrics.py +312 -0
  111. openhands/sdk/llm/utils/model_features.py +191 -0
  112. openhands/sdk/llm/utils/model_info.py +90 -0
  113. openhands/sdk/llm/utils/model_prompt_spec.py +98 -0
  114. openhands/sdk/llm/utils/retry_mixin.py +128 -0
  115. openhands/sdk/llm/utils/telemetry.py +362 -0
  116. openhands/sdk/llm/utils/unverified_models.py +156 -0
  117. openhands/sdk/llm/utils/verified_models.py +66 -0
  118. openhands/sdk/logger/__init__.py +22 -0
  119. openhands/sdk/logger/logger.py +195 -0
  120. openhands/sdk/logger/rolling.py +113 -0
  121. openhands/sdk/mcp/__init__.py +24 -0
  122. openhands/sdk/mcp/client.py +76 -0
  123. openhands/sdk/mcp/definition.py +106 -0
  124. openhands/sdk/mcp/exceptions.py +19 -0
  125. openhands/sdk/mcp/tool.py +270 -0
  126. openhands/sdk/mcp/utils.py +83 -0
  127. openhands/sdk/observability/__init__.py +4 -0
  128. openhands/sdk/observability/laminar.py +166 -0
  129. openhands/sdk/observability/utils.py +20 -0
  130. openhands/sdk/py.typed +0 -0
  131. openhands/sdk/secret/__init__.py +19 -0
  132. openhands/sdk/secret/secrets.py +92 -0
  133. openhands/sdk/security/__init__.py +6 -0
  134. openhands/sdk/security/analyzer.py +111 -0
  135. openhands/sdk/security/confirmation_policy.py +61 -0
  136. openhands/sdk/security/llm_analyzer.py +29 -0
  137. openhands/sdk/security/risk.py +100 -0
  138. openhands/sdk/tool/__init__.py +34 -0
  139. openhands/sdk/tool/builtins/__init__.py +34 -0
  140. openhands/sdk/tool/builtins/finish.py +106 -0
  141. openhands/sdk/tool/builtins/think.py +117 -0
  142. openhands/sdk/tool/registry.py +161 -0
  143. openhands/sdk/tool/schema.py +276 -0
  144. openhands/sdk/tool/spec.py +39 -0
  145. openhands/sdk/tool/tool.py +481 -0
  146. openhands/sdk/utils/__init__.py +22 -0
  147. openhands/sdk/utils/async_executor.py +115 -0
  148. openhands/sdk/utils/async_utils.py +39 -0
  149. openhands/sdk/utils/cipher.py +68 -0
  150. openhands/sdk/utils/command.py +90 -0
  151. openhands/sdk/utils/deprecation.py +166 -0
  152. openhands/sdk/utils/github.py +44 -0
  153. openhands/sdk/utils/json.py +48 -0
  154. openhands/sdk/utils/models.py +570 -0
  155. openhands/sdk/utils/paging.py +63 -0
  156. openhands/sdk/utils/pydantic_diff.py +85 -0
  157. openhands/sdk/utils/pydantic_secrets.py +64 -0
  158. openhands/sdk/utils/truncate.py +117 -0
  159. openhands/sdk/utils/visualize.py +58 -0
  160. openhands/sdk/workspace/__init__.py +17 -0
  161. openhands/sdk/workspace/base.py +158 -0
  162. openhands/sdk/workspace/local.py +189 -0
  163. openhands/sdk/workspace/models.py +35 -0
  164. openhands/sdk/workspace/remote/__init__.py +8 -0
  165. openhands/sdk/workspace/remote/async_remote_workspace.py +149 -0
  166. openhands/sdk/workspace/remote/base.py +164 -0
  167. openhands/sdk/workspace/remote/remote_workspace_mixin.py +323 -0
  168. openhands/sdk/workspace/workspace.py +49 -0
  169. openhands_sdk-1.7.0.dist-info/METADATA +17 -0
  170. openhands_sdk-1.7.0.dist-info/RECORD +172 -0
  171. openhands_sdk-1.7.0.dist-info/WHEEL +5 -0
  172. openhands_sdk-1.7.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env python3
2
+ """Get git diff in a single git file for the closest git repo in the file system"""
3
+
4
+ import json
5
+ import logging
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from openhands.sdk.git.exceptions import (
11
+ GitCommandError,
12
+ GitPathError,
13
+ GitRepositoryError,
14
+ )
15
+ from openhands.sdk.git.models import GitDiff
16
+ from openhands.sdk.git.utils import (
17
+ get_valid_ref,
18
+ run_git_command,
19
+ validate_git_repository,
20
+ )
21
+
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ MAX_FILE_SIZE_FOR_GIT_DIFF = 1024 * 1024 # 1 Mb
27
+
28
+
29
+ def get_closest_git_repo(path: Path) -> Path | None:
30
+ """Find the closest git repository by walking up the directory tree.
31
+
32
+ Args:
33
+ path: Starting path to search from
34
+
35
+ Returns:
36
+ Path to the git repository root, or None if not found
37
+ """
38
+ current_path = path.resolve()
39
+
40
+ while True:
41
+ git_path = current_path / ".git"
42
+ if git_path.exists(): # Could be file (worktree) or directory
43
+ logger.debug(f"Found git repository at: {current_path}")
44
+ return current_path
45
+
46
+ parent = current_path.parent
47
+ if parent == current_path: # Reached filesystem root
48
+ logger.debug(f"No git repository found for path: {path}")
49
+ return None
50
+ current_path = parent
51
+
52
+
53
+ def get_git_diff(relative_file_path: str | Path) -> GitDiff:
54
+ """Get git diff for a single file.
55
+
56
+ Args:
57
+ relative_file_path: Path to the file relative to current working directory
58
+
59
+ Returns:
60
+ GitDiff object containing diff information
61
+
62
+ Raises:
63
+ GitPathError: If file is too large or doesn't exist
64
+ GitRepositoryError: If not in a git repository
65
+ GitCommandError: If git commands fail
66
+ """
67
+ path = Path(os.getcwd(), relative_file_path).resolve()
68
+
69
+ # Check if file exists
70
+ if not path.exists():
71
+ raise GitPathError(f"File does not exist: {path}")
72
+
73
+ # Check file size
74
+ try:
75
+ file_size = os.path.getsize(path)
76
+ if file_size > MAX_FILE_SIZE_FOR_GIT_DIFF:
77
+ raise GitPathError(
78
+ f"File too large for git diff: {file_size} bytes "
79
+ f"(max: {MAX_FILE_SIZE_FOR_GIT_DIFF} bytes)"
80
+ )
81
+ except OSError as e:
82
+ raise GitPathError(f"Cannot access file: {path}") from e
83
+
84
+ # Find git repository
85
+ closest_git_repo = get_closest_git_repo(path)
86
+ if not closest_git_repo:
87
+ raise GitRepositoryError(f"File is not in a git repository: {path}")
88
+
89
+ # Validate the git repository
90
+ validated_repo = validate_git_repository(closest_git_repo)
91
+
92
+ current_rev = get_valid_ref(validated_repo)
93
+ if not current_rev:
94
+ logger.warning(f"No valid git reference found for {validated_repo}")
95
+ return GitDiff(modified="", original="")
96
+
97
+ # Get the relative path from the git repo root
98
+ try:
99
+ relative_path_from_repo = path.relative_to(validated_repo)
100
+ except ValueError as e:
101
+ raise GitPathError(f"File is not within git repository: {path}") from e
102
+
103
+ # Get old content (from the ref)
104
+ try:
105
+ original = run_git_command(
106
+ ["git", "show", f"{current_rev}:{relative_path_from_repo}"], validated_repo
107
+ )
108
+ except GitCommandError:
109
+ logger.debug(f"No old content found for {path} at ref {current_rev}")
110
+ original = ""
111
+
112
+ # Get new content (current file)
113
+ try:
114
+ with open(path, encoding="utf-8") as f:
115
+ modified = "\n".join(f.read().splitlines())
116
+ except (OSError, UnicodeDecodeError) as e:
117
+ logger.error(f"Failed to read file {path}: {e}")
118
+ modified = ""
119
+
120
+ logger.info(f"Generated git diff for {path}")
121
+ return GitDiff(
122
+ modified=modified,
123
+ original=original,
124
+ )
125
+
126
+
127
+ if __name__ == "__main__":
128
+ diff = get_git_diff(sys.argv[-1])
129
+ print(json.dumps(diff))
@@ -0,0 +1,21 @@
1
+ from enum import Enum
2
+ from pathlib import Path
3
+
4
+ from pydantic import BaseModel
5
+
6
+
7
+ class GitChangeStatus(Enum):
8
+ MOVED = "MOVED"
9
+ ADDED = "ADDED"
10
+ DELETED = "DELETED"
11
+ UPDATED = "UPDATED"
12
+
13
+
14
+ class GitChange(BaseModel):
15
+ status: GitChangeStatus
16
+ path: Path
17
+
18
+
19
+ class GitDiff(BaseModel):
20
+ modified: str | None
21
+ original: str | None
@@ -0,0 +1,189 @@
1
+ import logging
2
+ import shlex
3
+ import subprocess
4
+ from pathlib import Path
5
+
6
+ from openhands.sdk.git.exceptions import GitCommandError, GitRepositoryError
7
+
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ # Git empty tree hash - this is a well-known constant in git
12
+ # representing the hash of an empty tree object
13
+ GIT_EMPTY_TREE_HASH = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
14
+
15
+
16
+ def run_git_command(args: list[str], cwd: str | Path) -> str:
17
+ """Run a git command safely without shell injection vulnerabilities.
18
+
19
+ Args:
20
+ args: List of command arguments (e.g., ['git', 'status', '--porcelain'])
21
+ cwd: Working directory to run the command in
22
+
23
+ Returns:
24
+ Command output as string
25
+
26
+ Raises:
27
+ GitCommandError: If the git command fails
28
+ """
29
+ try:
30
+ result = subprocess.run(
31
+ args,
32
+ cwd=cwd,
33
+ capture_output=True,
34
+ text=True,
35
+ check=False,
36
+ timeout=30, # Prevent hanging commands
37
+ )
38
+
39
+ if result.returncode != 0:
40
+ cmd_str = shlex.join(args)
41
+ error_msg = f"Git command failed: {cmd_str}"
42
+ logger.error(
43
+ f"{error_msg}. Exit code: {result.returncode}. Stderr: {result.stderr}"
44
+ )
45
+ raise GitCommandError(
46
+ message=error_msg,
47
+ command=args,
48
+ exit_code=result.returncode,
49
+ stderr=result.stderr.strip(),
50
+ )
51
+
52
+ logger.debug(f"Git command succeeded: {shlex.join(args)}")
53
+ return result.stdout.strip()
54
+
55
+ except subprocess.TimeoutExpired as e:
56
+ cmd_str = shlex.join(args)
57
+ error_msg = f"Git command timed out: {cmd_str}"
58
+ logger.error(error_msg)
59
+ raise GitCommandError(
60
+ message=error_msg,
61
+ command=args,
62
+ exit_code=-1,
63
+ stderr="Command timed out",
64
+ ) from e
65
+ except FileNotFoundError as e:
66
+ error_msg = "Git command not found. Is git installed?"
67
+ logger.error(error_msg)
68
+ raise GitCommandError(
69
+ message=error_msg,
70
+ command=args,
71
+ exit_code=-1,
72
+ stderr="Git executable not found",
73
+ ) from e
74
+
75
+
76
+ def get_valid_ref(repo_dir: str | Path) -> str | None:
77
+ """Get a valid git reference to compare against.
78
+
79
+ Tries multiple strategies to find a valid reference:
80
+ 1. Current branch's origin (e.g., origin/main)
81
+ 2. Default branch (e.g., origin/main, origin/master)
82
+ 3. Merge base with default branch
83
+ 4. Empty tree (for new repositories)
84
+
85
+ Args:
86
+ repo_dir: Path to the git repository
87
+
88
+ Returns:
89
+ Valid git reference hash, or None if no valid reference found
90
+ """
91
+ refs_to_try = []
92
+
93
+ # Try current branch's origin
94
+ try:
95
+ current_branch = run_git_command(
96
+ ["git", "--no-pager", "rev-parse", "--abbrev-ref", "HEAD"], repo_dir
97
+ )
98
+ if current_branch and current_branch != "HEAD": # Not in detached HEAD state
99
+ refs_to_try.append(f"origin/{current_branch}")
100
+ logger.debug(f"Added current branch reference: origin/{current_branch}")
101
+ except GitCommandError:
102
+ logger.debug("Could not get current branch name")
103
+
104
+ # Try to get default branch from remote
105
+ try:
106
+ remote_info = run_git_command(
107
+ ["git", "--no-pager", "remote", "show", "origin"], repo_dir
108
+ )
109
+ for line in remote_info.splitlines():
110
+ if "HEAD branch:" in line:
111
+ default_branch = line.split(":")[-1].strip()
112
+ if default_branch:
113
+ refs_to_try.append(f"origin/{default_branch}")
114
+ logger.debug(
115
+ f"Added default branch reference: origin/{default_branch}"
116
+ )
117
+
118
+ # Also try merge base with default branch
119
+ try:
120
+ merge_base = run_git_command(
121
+ [
122
+ "git",
123
+ "--no-pager",
124
+ "merge-base",
125
+ "HEAD",
126
+ f"origin/{default_branch}",
127
+ ],
128
+ repo_dir,
129
+ )
130
+ if merge_base:
131
+ refs_to_try.append(merge_base)
132
+ logger.debug(f"Added merge base reference: {merge_base}")
133
+ except GitCommandError:
134
+ logger.debug("Could not get merge base")
135
+ break
136
+ except GitCommandError:
137
+ logger.debug("Could not get remote information")
138
+
139
+ # Add empty tree as fallback for new repositories
140
+ refs_to_try.append(GIT_EMPTY_TREE_HASH)
141
+ logger.debug(f"Added empty tree reference: {GIT_EMPTY_TREE_HASH}")
142
+
143
+ # Find the first valid reference
144
+ for ref in refs_to_try:
145
+ try:
146
+ result = run_git_command(
147
+ ["git", "--no-pager", "rev-parse", "--verify", ref], repo_dir
148
+ )
149
+ if result:
150
+ logger.debug(f"Using valid reference: {ref} -> {result}")
151
+ return result
152
+ except GitCommandError:
153
+ logger.debug(f"Reference not valid: {ref}")
154
+ continue
155
+
156
+ logger.warning("No valid git reference found")
157
+ return None
158
+
159
+
160
+ def validate_git_repository(repo_dir: str | Path) -> Path:
161
+ """Validate that the given directory is a git repository.
162
+
163
+ Args:
164
+ repo_dir: Path to check
165
+
166
+ Returns:
167
+ Validated Path object
168
+
169
+ Raises:
170
+ GitRepositoryError: If not a valid git repository
171
+ """
172
+ repo_path = Path(repo_dir).resolve()
173
+
174
+ if not repo_path.exists():
175
+ raise GitRepositoryError(f"Directory does not exist: {repo_path}")
176
+
177
+ if not repo_path.is_dir():
178
+ raise GitRepositoryError(f"Path is not a directory: {repo_path}")
179
+
180
+ # Check if it's a git repository by looking for .git directory or file
181
+ git_dir = repo_path / ".git"
182
+ if not git_dir.exists():
183
+ # Maybe we're in a subdirectory, try to find the git root
184
+ try:
185
+ run_git_command(["git", "rev-parse", "--git-dir"], repo_path)
186
+ except GitCommandError as e:
187
+ raise GitRepositoryError(f"Not a git repository: {repo_path}") from e
188
+
189
+ return repo_path
@@ -0,0 +1,6 @@
1
+ from .base import FileStore
2
+ from .local import LocalFileStore
3
+ from .memory import InMemoryFileStore
4
+
5
+
6
+ __all__ = ["LocalFileStore", "FileStore", "InMemoryFileStore"]
@@ -0,0 +1,48 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+
4
+ class FileStore(ABC):
5
+ """Abstract base class for file storage operations.
6
+
7
+ This class defines the interface for file storage backends that can
8
+ handle basic file operations like reading, writing, listing, and deleting files.
9
+ """
10
+
11
+ @abstractmethod
12
+ def write(self, path: str, contents: str | bytes) -> None:
13
+ """Write contents to a file at the specified path.
14
+
15
+ Args:
16
+ path: The file path where contents should be written.
17
+ contents: The data to write, either as string or bytes.
18
+ """
19
+
20
+ @abstractmethod
21
+ def read(self, path: str) -> str:
22
+ """Read and return the contents of a file as a string.
23
+
24
+ Args:
25
+ path: The file path to read from.
26
+
27
+ Returns:
28
+ The file contents as a string.
29
+ """
30
+
31
+ @abstractmethod
32
+ def list(self, path: str) -> list[str]:
33
+ """List all files and directories at the specified path.
34
+
35
+ Args:
36
+ path: The directory path to list contents from.
37
+
38
+ Returns:
39
+ A list of file and directory names in the specified path.
40
+ """
41
+
42
+ @abstractmethod
43
+ def delete(self, path: str) -> None:
44
+ """Delete the file or directory at the specified path.
45
+
46
+ Args:
47
+ path: The file or directory path to delete.
48
+ """
@@ -0,0 +1,82 @@
1
+ import os
2
+ import shutil
3
+
4
+ from openhands.sdk.logger import get_logger
5
+ from openhands.sdk.observability.laminar import observe
6
+
7
+ from .base import FileStore
8
+
9
+
10
+ logger = get_logger(__name__)
11
+
12
+
13
+ class LocalFileStore(FileStore):
14
+ root: str
15
+
16
+ def __init__(self, root: str):
17
+ if root.startswith("~"):
18
+ root = os.path.expanduser(root)
19
+ root = os.path.abspath(os.path.normpath(root))
20
+ self.root = root
21
+ os.makedirs(self.root, exist_ok=True)
22
+
23
+ def get_full_path(self, path: str) -> str:
24
+ # strip leading slash to keep relative under root
25
+ if path.startswith("/"):
26
+ path = path[1:]
27
+ # normalize path separators to handle both Unix (/) and Windows (\) styles
28
+ normalized_path = path.replace("\\", "/")
29
+ full = os.path.abspath(
30
+ os.path.normpath(os.path.join(self.root, normalized_path))
31
+ )
32
+ # ensure sandboxing
33
+ if os.path.commonpath([self.root, full]) != self.root:
34
+ raise ValueError(f"path escapes filestore root: {path}")
35
+ return full
36
+
37
+ @observe(name="LocalFileStore.write", span_type="TOOL")
38
+ def write(self, path: str, contents: str | bytes) -> None:
39
+ full_path = self.get_full_path(path)
40
+ os.makedirs(os.path.dirname(full_path), exist_ok=True)
41
+ if isinstance(contents, str):
42
+ with open(full_path, "w", encoding="utf-8") as f:
43
+ f.write(contents)
44
+ else:
45
+ with open(full_path, "wb") as f:
46
+ f.write(contents)
47
+
48
+ def read(self, path: str) -> str:
49
+ full_path = self.get_full_path(path)
50
+ with open(full_path, encoding="utf-8") as f:
51
+ return f.read()
52
+
53
+ @observe(name="LocalFileStore.list", span_type="TOOL")
54
+ def list(self, path: str) -> list[str]:
55
+ full_path = self.get_full_path(path)
56
+ if not os.path.exists(full_path):
57
+ return []
58
+
59
+ # If path is a file, return the file itself (S3-consistent behavior)
60
+ if os.path.isfile(full_path):
61
+ return [path]
62
+
63
+ # Otherwise it's a directory, return its contents
64
+ files = [os.path.join(path, f) for f in os.listdir(full_path)]
65
+ files = [f + "/" if os.path.isdir(self.get_full_path(f)) else f for f in files]
66
+ return files
67
+
68
+ @observe(name="LocalFileStore.delete", span_type="TOOL")
69
+ def delete(self, path: str) -> None:
70
+ try:
71
+ full_path = self.get_full_path(path)
72
+ if not os.path.exists(full_path):
73
+ logger.debug(f"Local path does not exist: {full_path}")
74
+ return
75
+ if os.path.isfile(full_path):
76
+ os.remove(full_path)
77
+ logger.debug(f"Removed local file: {full_path}")
78
+ elif os.path.isdir(full_path):
79
+ shutil.rmtree(full_path)
80
+ logger.debug(f"Removed local directory: {full_path}")
81
+ except Exception as e:
82
+ logger.error(f"Error clearing local file store: {str(e)}")
@@ -0,0 +1,54 @@
1
+ import os
2
+
3
+ from openhands.sdk.io.base import FileStore
4
+ from openhands.sdk.logger import get_logger
5
+
6
+
7
+ logger = get_logger(__name__)
8
+
9
+
10
+ class InMemoryFileStore(FileStore):
11
+ files: dict[str, str]
12
+
13
+ def __init__(self, files: dict[str, str] | None = None) -> None:
14
+ self.files = {}
15
+ if files is not None:
16
+ self.files = files
17
+
18
+ def write(self, path: str, contents: str | bytes) -> None:
19
+ if isinstance(contents, bytes):
20
+ contents = contents.decode("utf-8")
21
+ self.files[path] = contents
22
+
23
+ def read(self, path: str) -> str:
24
+ if path not in self.files:
25
+ raise FileNotFoundError(path)
26
+ return self.files[path]
27
+
28
+ def list(self, path: str) -> list[str]:
29
+ files = []
30
+ for file in self.files:
31
+ if not file.startswith(path):
32
+ continue
33
+ suffix = file.removeprefix(path)
34
+ parts = suffix.split("/")
35
+ if parts[0] == "":
36
+ parts.pop(0)
37
+ if len(parts) == 1:
38
+ files.append(file)
39
+ else:
40
+ dir_path = os.path.join(path, parts[0])
41
+ if not dir_path.endswith("/"):
42
+ dir_path += "/"
43
+ if dir_path not in files:
44
+ files.append(dir_path)
45
+ return files
46
+
47
+ def delete(self, path: str) -> None:
48
+ try:
49
+ keys_to_delete = [key for key in self.files.keys() if key.startswith(path)]
50
+ for key in keys_to_delete:
51
+ del self.files[key]
52
+ logger.debug(f"Cleared in-memory file store: {path}")
53
+ except Exception as e:
54
+ logger.error(f"Error clearing in-memory file store: {str(e)}")
@@ -0,0 +1,45 @@
1
+ from openhands.sdk.llm.llm import LLM
2
+ from openhands.sdk.llm.llm_registry import LLMRegistry, RegistryEvent
3
+ from openhands.sdk.llm.llm_response import LLMResponse
4
+ from openhands.sdk.llm.message import (
5
+ ImageContent,
6
+ Message,
7
+ MessageToolCall,
8
+ ReasoningItemModel,
9
+ RedactedThinkingBlock,
10
+ TextContent,
11
+ ThinkingBlock,
12
+ content_to_str,
13
+ )
14
+ from openhands.sdk.llm.router import RouterLLM
15
+ from openhands.sdk.llm.streaming import LLMStreamChunk, TokenCallbackType
16
+ from openhands.sdk.llm.utils.metrics import Metrics, MetricsSnapshot
17
+ from openhands.sdk.llm.utils.unverified_models import (
18
+ UNVERIFIED_MODELS_EXCLUDING_BEDROCK,
19
+ get_unverified_models,
20
+ )
21
+ from openhands.sdk.llm.utils.verified_models import VERIFIED_MODELS
22
+
23
+
24
+ __all__ = [
25
+ "LLMResponse",
26
+ "LLM",
27
+ "LLMRegistry",
28
+ "RouterLLM",
29
+ "RegistryEvent",
30
+ "Message",
31
+ "MessageToolCall",
32
+ "TextContent",
33
+ "ImageContent",
34
+ "ThinkingBlock",
35
+ "RedactedThinkingBlock",
36
+ "ReasoningItemModel",
37
+ "content_to_str",
38
+ "LLMStreamChunk",
39
+ "TokenCallbackType",
40
+ "Metrics",
41
+ "MetricsSnapshot",
42
+ "VERIFIED_MODELS",
43
+ "UNVERIFIED_MODELS_EXCLUDING_BEDROCK",
44
+ "get_unverified_models",
45
+ ]
@@ -0,0 +1,45 @@
1
+ from .classifier import is_context_window_exceeded, looks_like_auth_error
2
+ from .mapping import map_provider_exception
3
+ from .types import (
4
+ FunctionCallConversionError,
5
+ FunctionCallNotExistsError,
6
+ FunctionCallValidationError,
7
+ LLMAuthenticationError,
8
+ LLMBadRequestError,
9
+ LLMContextWindowExceedError,
10
+ LLMError,
11
+ LLMMalformedActionError,
12
+ LLMNoActionError,
13
+ LLMNoResponseError,
14
+ LLMRateLimitError,
15
+ LLMResponseError,
16
+ LLMServiceUnavailableError,
17
+ LLMTimeoutError,
18
+ OperationCancelled,
19
+ UserCancelledError,
20
+ )
21
+
22
+
23
+ __all__ = [
24
+ # Types
25
+ "LLMError",
26
+ "LLMMalformedActionError",
27
+ "LLMNoActionError",
28
+ "LLMResponseError",
29
+ "FunctionCallConversionError",
30
+ "FunctionCallValidationError",
31
+ "FunctionCallNotExistsError",
32
+ "LLMNoResponseError",
33
+ "LLMContextWindowExceedError",
34
+ "LLMAuthenticationError",
35
+ "LLMRateLimitError",
36
+ "LLMTimeoutError",
37
+ "LLMServiceUnavailableError",
38
+ "LLMBadRequestError",
39
+ "UserCancelledError",
40
+ "OperationCancelled",
41
+ # Helpers
42
+ "is_context_window_exceeded",
43
+ "looks_like_auth_error",
44
+ "map_provider_exception",
45
+ ]
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ from litellm.exceptions import BadRequestError, ContextWindowExceededError, OpenAIError
4
+
5
+ from .types import LLMContextWindowExceedError
6
+
7
+
8
+ # Minimal, provider-agnostic context-window detection
9
+ LONG_PROMPT_PATTERNS: list[str] = [
10
+ "contextwindowexceedederror",
11
+ "prompt is too long",
12
+ "input length and `max_tokens` exceed context limit",
13
+ "please reduce the length of",
14
+ "the request exceeds the available context size",
15
+ "context length exceeded",
16
+ "input exceeds the context window",
17
+ ]
18
+
19
+
20
+ def is_context_window_exceeded(exception: Exception) -> bool:
21
+ if isinstance(exception, (ContextWindowExceededError, LLMContextWindowExceedError)):
22
+ return True
23
+
24
+ if not isinstance(exception, (BadRequestError, OpenAIError)):
25
+ return False
26
+
27
+ s = str(exception).lower()
28
+ return any(p in s for p in LONG_PROMPT_PATTERNS)
29
+
30
+
31
+ AUTH_PATTERNS: list[str] = [
32
+ "invalid api key",
33
+ "unauthorized",
34
+ "missing api key",
35
+ "invalid authentication",
36
+ "access denied",
37
+ ]
38
+
39
+
40
+ def looks_like_auth_error(exception: Exception) -> bool:
41
+ if not isinstance(exception, (BadRequestError, OpenAIError)):
42
+ return False
43
+ s = str(exception).lower()
44
+ if any(p in s for p in AUTH_PATTERNS):
45
+ return True
46
+ # Some providers include explicit status codes in message text
47
+ for code in ("status 401", "status 403"):
48
+ if code in s:
49
+ return True
50
+ return False