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.
- openhands/sdk/__init__.py +111 -0
- openhands/sdk/agent/__init__.py +8 -0
- openhands/sdk/agent/agent.py +607 -0
- openhands/sdk/agent/base.py +454 -0
- openhands/sdk/agent/prompts/in_context_learning_example.j2 +169 -0
- openhands/sdk/agent/prompts/in_context_learning_example_suffix.j2 +3 -0
- openhands/sdk/agent/prompts/model_specific/anthropic_claude.j2 +3 -0
- openhands/sdk/agent/prompts/model_specific/google_gemini.j2 +1 -0
- openhands/sdk/agent/prompts/model_specific/openai_gpt/gpt-5-codex.j2 +3 -0
- openhands/sdk/agent/prompts/model_specific/openai_gpt/gpt-5.j2 +3 -0
- openhands/sdk/agent/prompts/security_policy.j2 +22 -0
- openhands/sdk/agent/prompts/security_risk_assessment.j2 +21 -0
- openhands/sdk/agent/prompts/self_documentation.j2 +15 -0
- openhands/sdk/agent/prompts/system_prompt.j2 +132 -0
- openhands/sdk/agent/prompts/system_prompt_interactive.j2 +14 -0
- openhands/sdk/agent/prompts/system_prompt_long_horizon.j2 +40 -0
- openhands/sdk/agent/prompts/system_prompt_planning.j2 +40 -0
- openhands/sdk/agent/prompts/system_prompt_tech_philosophy.j2 +122 -0
- openhands/sdk/agent/utils.py +223 -0
- openhands/sdk/context/__init__.py +28 -0
- openhands/sdk/context/agent_context.py +240 -0
- openhands/sdk/context/condenser/__init__.py +18 -0
- openhands/sdk/context/condenser/base.py +95 -0
- openhands/sdk/context/condenser/llm_summarizing_condenser.py +89 -0
- openhands/sdk/context/condenser/no_op_condenser.py +13 -0
- openhands/sdk/context/condenser/pipeline_condenser.py +55 -0
- openhands/sdk/context/condenser/prompts/summarizing_prompt.j2 +59 -0
- openhands/sdk/context/prompts/__init__.py +6 -0
- openhands/sdk/context/prompts/prompt.py +114 -0
- openhands/sdk/context/prompts/templates/ask_agent_template.j2 +11 -0
- openhands/sdk/context/prompts/templates/skill_knowledge_info.j2 +8 -0
- openhands/sdk/context/prompts/templates/system_message_suffix.j2 +32 -0
- openhands/sdk/context/skills/__init__.py +28 -0
- openhands/sdk/context/skills/exceptions.py +11 -0
- openhands/sdk/context/skills/skill.py +630 -0
- openhands/sdk/context/skills/trigger.py +36 -0
- openhands/sdk/context/skills/types.py +48 -0
- openhands/sdk/context/view.py +306 -0
- openhands/sdk/conversation/__init__.py +40 -0
- openhands/sdk/conversation/base.py +281 -0
- openhands/sdk/conversation/conversation.py +146 -0
- openhands/sdk/conversation/conversation_stats.py +85 -0
- openhands/sdk/conversation/event_store.py +157 -0
- openhands/sdk/conversation/events_list_base.py +17 -0
- openhands/sdk/conversation/exceptions.py +50 -0
- openhands/sdk/conversation/fifo_lock.py +133 -0
- openhands/sdk/conversation/impl/__init__.py +5 -0
- openhands/sdk/conversation/impl/local_conversation.py +620 -0
- openhands/sdk/conversation/impl/remote_conversation.py +883 -0
- openhands/sdk/conversation/persistence_const.py +9 -0
- openhands/sdk/conversation/response_utils.py +41 -0
- openhands/sdk/conversation/secret_registry.py +126 -0
- openhands/sdk/conversation/serialization_diff.py +0 -0
- openhands/sdk/conversation/state.py +352 -0
- openhands/sdk/conversation/stuck_detector.py +311 -0
- openhands/sdk/conversation/title_utils.py +191 -0
- openhands/sdk/conversation/types.py +45 -0
- openhands/sdk/conversation/visualizer/__init__.py +12 -0
- openhands/sdk/conversation/visualizer/base.py +67 -0
- openhands/sdk/conversation/visualizer/default.py +373 -0
- openhands/sdk/critic/__init__.py +15 -0
- openhands/sdk/critic/base.py +38 -0
- openhands/sdk/critic/impl/__init__.py +12 -0
- openhands/sdk/critic/impl/agent_finished.py +83 -0
- openhands/sdk/critic/impl/empty_patch.py +49 -0
- openhands/sdk/critic/impl/pass_critic.py +42 -0
- openhands/sdk/event/__init__.py +42 -0
- openhands/sdk/event/base.py +149 -0
- openhands/sdk/event/condenser.py +82 -0
- openhands/sdk/event/conversation_error.py +25 -0
- openhands/sdk/event/conversation_state.py +104 -0
- openhands/sdk/event/llm_completion_log.py +39 -0
- openhands/sdk/event/llm_convertible/__init__.py +20 -0
- openhands/sdk/event/llm_convertible/action.py +139 -0
- openhands/sdk/event/llm_convertible/message.py +142 -0
- openhands/sdk/event/llm_convertible/observation.py +141 -0
- openhands/sdk/event/llm_convertible/system.py +61 -0
- openhands/sdk/event/token.py +16 -0
- openhands/sdk/event/types.py +11 -0
- openhands/sdk/event/user_action.py +21 -0
- openhands/sdk/git/exceptions.py +43 -0
- openhands/sdk/git/git_changes.py +249 -0
- openhands/sdk/git/git_diff.py +129 -0
- openhands/sdk/git/models.py +21 -0
- openhands/sdk/git/utils.py +189 -0
- openhands/sdk/io/__init__.py +6 -0
- openhands/sdk/io/base.py +48 -0
- openhands/sdk/io/local.py +82 -0
- openhands/sdk/io/memory.py +54 -0
- openhands/sdk/llm/__init__.py +45 -0
- openhands/sdk/llm/exceptions/__init__.py +45 -0
- openhands/sdk/llm/exceptions/classifier.py +50 -0
- openhands/sdk/llm/exceptions/mapping.py +54 -0
- openhands/sdk/llm/exceptions/types.py +101 -0
- openhands/sdk/llm/llm.py +1140 -0
- openhands/sdk/llm/llm_registry.py +122 -0
- openhands/sdk/llm/llm_response.py +59 -0
- openhands/sdk/llm/message.py +656 -0
- openhands/sdk/llm/mixins/fn_call_converter.py +1243 -0
- openhands/sdk/llm/mixins/non_native_fc.py +93 -0
- openhands/sdk/llm/options/__init__.py +1 -0
- openhands/sdk/llm/options/chat_options.py +93 -0
- openhands/sdk/llm/options/common.py +19 -0
- openhands/sdk/llm/options/responses_options.py +67 -0
- openhands/sdk/llm/router/__init__.py +10 -0
- openhands/sdk/llm/router/base.py +117 -0
- openhands/sdk/llm/router/impl/multimodal.py +76 -0
- openhands/sdk/llm/router/impl/random.py +22 -0
- openhands/sdk/llm/streaming.py +9 -0
- openhands/sdk/llm/utils/metrics.py +312 -0
- openhands/sdk/llm/utils/model_features.py +191 -0
- openhands/sdk/llm/utils/model_info.py +90 -0
- openhands/sdk/llm/utils/model_prompt_spec.py +98 -0
- openhands/sdk/llm/utils/retry_mixin.py +128 -0
- openhands/sdk/llm/utils/telemetry.py +362 -0
- openhands/sdk/llm/utils/unverified_models.py +156 -0
- openhands/sdk/llm/utils/verified_models.py +66 -0
- openhands/sdk/logger/__init__.py +22 -0
- openhands/sdk/logger/logger.py +195 -0
- openhands/sdk/logger/rolling.py +113 -0
- openhands/sdk/mcp/__init__.py +24 -0
- openhands/sdk/mcp/client.py +76 -0
- openhands/sdk/mcp/definition.py +106 -0
- openhands/sdk/mcp/exceptions.py +19 -0
- openhands/sdk/mcp/tool.py +270 -0
- openhands/sdk/mcp/utils.py +83 -0
- openhands/sdk/observability/__init__.py +4 -0
- openhands/sdk/observability/laminar.py +166 -0
- openhands/sdk/observability/utils.py +20 -0
- openhands/sdk/py.typed +0 -0
- openhands/sdk/secret/__init__.py +19 -0
- openhands/sdk/secret/secrets.py +92 -0
- openhands/sdk/security/__init__.py +6 -0
- openhands/sdk/security/analyzer.py +111 -0
- openhands/sdk/security/confirmation_policy.py +61 -0
- openhands/sdk/security/llm_analyzer.py +29 -0
- openhands/sdk/security/risk.py +100 -0
- openhands/sdk/tool/__init__.py +34 -0
- openhands/sdk/tool/builtins/__init__.py +34 -0
- openhands/sdk/tool/builtins/finish.py +106 -0
- openhands/sdk/tool/builtins/think.py +117 -0
- openhands/sdk/tool/registry.py +161 -0
- openhands/sdk/tool/schema.py +276 -0
- openhands/sdk/tool/spec.py +39 -0
- openhands/sdk/tool/tool.py +481 -0
- openhands/sdk/utils/__init__.py +22 -0
- openhands/sdk/utils/async_executor.py +115 -0
- openhands/sdk/utils/async_utils.py +39 -0
- openhands/sdk/utils/cipher.py +68 -0
- openhands/sdk/utils/command.py +90 -0
- openhands/sdk/utils/deprecation.py +166 -0
- openhands/sdk/utils/github.py +44 -0
- openhands/sdk/utils/json.py +48 -0
- openhands/sdk/utils/models.py +570 -0
- openhands/sdk/utils/paging.py +63 -0
- openhands/sdk/utils/pydantic_diff.py +85 -0
- openhands/sdk/utils/pydantic_secrets.py +64 -0
- openhands/sdk/utils/truncate.py +117 -0
- openhands/sdk/utils/visualize.py +58 -0
- openhands/sdk/workspace/__init__.py +17 -0
- openhands/sdk/workspace/base.py +158 -0
- openhands/sdk/workspace/local.py +189 -0
- openhands/sdk/workspace/models.py +35 -0
- openhands/sdk/workspace/remote/__init__.py +8 -0
- openhands/sdk/workspace/remote/async_remote_workspace.py +149 -0
- openhands/sdk/workspace/remote/base.py +164 -0
- openhands/sdk/workspace/remote/remote_workspace_mixin.py +323 -0
- openhands/sdk/workspace/workspace.py +49 -0
- openhands_sdk-1.7.0.dist-info/METADATA +17 -0
- openhands_sdk-1.7.0.dist-info/RECORD +172 -0
- openhands_sdk-1.7.0.dist-info/WHEEL +5 -0
- 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
|
openhands/sdk/io/base.py
ADDED
|
@@ -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
|