openhands-sdk 1.7.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openhands/sdk/__init__.py +111 -0
- openhands/sdk/agent/__init__.py +8 -0
- openhands/sdk/agent/agent.py +650 -0
- openhands/sdk/agent/base.py +457 -0
- openhands/sdk/agent/prompts/in_context_learning_example.j2 +169 -0
- openhands/sdk/agent/prompts/in_context_learning_example_suffix.j2 +3 -0
- openhands/sdk/agent/prompts/model_specific/anthropic_claude.j2 +3 -0
- openhands/sdk/agent/prompts/model_specific/google_gemini.j2 +1 -0
- openhands/sdk/agent/prompts/model_specific/openai_gpt/gpt-5-codex.j2 +2 -0
- openhands/sdk/agent/prompts/model_specific/openai_gpt/gpt-5.j2 +3 -0
- openhands/sdk/agent/prompts/security_policy.j2 +22 -0
- openhands/sdk/agent/prompts/security_risk_assessment.j2 +21 -0
- openhands/sdk/agent/prompts/self_documentation.j2 +15 -0
- openhands/sdk/agent/prompts/system_prompt.j2 +132 -0
- openhands/sdk/agent/prompts/system_prompt_interactive.j2 +14 -0
- openhands/sdk/agent/prompts/system_prompt_long_horizon.j2 +40 -0
- openhands/sdk/agent/prompts/system_prompt_planning.j2 +40 -0
- openhands/sdk/agent/prompts/system_prompt_tech_philosophy.j2 +122 -0
- openhands/sdk/agent/utils.py +228 -0
- openhands/sdk/context/__init__.py +28 -0
- openhands/sdk/context/agent_context.py +264 -0
- openhands/sdk/context/condenser/__init__.py +18 -0
- openhands/sdk/context/condenser/base.py +100 -0
- openhands/sdk/context/condenser/llm_summarizing_condenser.py +248 -0
- openhands/sdk/context/condenser/no_op_condenser.py +14 -0
- openhands/sdk/context/condenser/pipeline_condenser.py +56 -0
- openhands/sdk/context/condenser/prompts/summarizing_prompt.j2 +59 -0
- openhands/sdk/context/condenser/utils.py +149 -0
- openhands/sdk/context/prompts/__init__.py +6 -0
- openhands/sdk/context/prompts/prompt.py +114 -0
- openhands/sdk/context/prompts/templates/ask_agent_template.j2 +11 -0
- openhands/sdk/context/prompts/templates/skill_knowledge_info.j2 +8 -0
- openhands/sdk/context/prompts/templates/system_message_suffix.j2 +32 -0
- openhands/sdk/context/skills/__init__.py +28 -0
- openhands/sdk/context/skills/exceptions.py +11 -0
- openhands/sdk/context/skills/skill.py +720 -0
- openhands/sdk/context/skills/trigger.py +36 -0
- openhands/sdk/context/skills/types.py +48 -0
- openhands/sdk/context/view.py +503 -0
- openhands/sdk/conversation/__init__.py +40 -0
- openhands/sdk/conversation/base.py +281 -0
- openhands/sdk/conversation/conversation.py +152 -0
- openhands/sdk/conversation/conversation_stats.py +85 -0
- openhands/sdk/conversation/event_store.py +157 -0
- openhands/sdk/conversation/events_list_base.py +17 -0
- openhands/sdk/conversation/exceptions.py +50 -0
- openhands/sdk/conversation/fifo_lock.py +133 -0
- openhands/sdk/conversation/impl/__init__.py +5 -0
- openhands/sdk/conversation/impl/local_conversation.py +665 -0
- openhands/sdk/conversation/impl/remote_conversation.py +956 -0
- openhands/sdk/conversation/persistence_const.py +9 -0
- openhands/sdk/conversation/response_utils.py +41 -0
- openhands/sdk/conversation/secret_registry.py +126 -0
- openhands/sdk/conversation/serialization_diff.py +0 -0
- openhands/sdk/conversation/state.py +392 -0
- openhands/sdk/conversation/stuck_detector.py +311 -0
- openhands/sdk/conversation/title_utils.py +191 -0
- openhands/sdk/conversation/types.py +45 -0
- openhands/sdk/conversation/visualizer/__init__.py +12 -0
- openhands/sdk/conversation/visualizer/base.py +67 -0
- openhands/sdk/conversation/visualizer/default.py +373 -0
- openhands/sdk/critic/__init__.py +15 -0
- openhands/sdk/critic/base.py +38 -0
- openhands/sdk/critic/impl/__init__.py +12 -0
- openhands/sdk/critic/impl/agent_finished.py +83 -0
- openhands/sdk/critic/impl/empty_patch.py +49 -0
- openhands/sdk/critic/impl/pass_critic.py +42 -0
- openhands/sdk/event/__init__.py +42 -0
- openhands/sdk/event/base.py +149 -0
- openhands/sdk/event/condenser.py +82 -0
- openhands/sdk/event/conversation_error.py +25 -0
- openhands/sdk/event/conversation_state.py +104 -0
- openhands/sdk/event/llm_completion_log.py +39 -0
- openhands/sdk/event/llm_convertible/__init__.py +20 -0
- openhands/sdk/event/llm_convertible/action.py +139 -0
- openhands/sdk/event/llm_convertible/message.py +142 -0
- openhands/sdk/event/llm_convertible/observation.py +141 -0
- openhands/sdk/event/llm_convertible/system.py +61 -0
- openhands/sdk/event/token.py +16 -0
- openhands/sdk/event/types.py +11 -0
- openhands/sdk/event/user_action.py +21 -0
- openhands/sdk/git/exceptions.py +43 -0
- openhands/sdk/git/git_changes.py +249 -0
- openhands/sdk/git/git_diff.py +129 -0
- openhands/sdk/git/models.py +21 -0
- openhands/sdk/git/utils.py +189 -0
- openhands/sdk/hooks/__init__.py +30 -0
- openhands/sdk/hooks/config.py +180 -0
- openhands/sdk/hooks/conversation_hooks.py +227 -0
- openhands/sdk/hooks/executor.py +155 -0
- openhands/sdk/hooks/manager.py +170 -0
- openhands/sdk/hooks/types.py +40 -0
- openhands/sdk/io/__init__.py +6 -0
- openhands/sdk/io/base.py +48 -0
- openhands/sdk/io/cache.py +85 -0
- openhands/sdk/io/local.py +119 -0
- openhands/sdk/io/memory.py +54 -0
- openhands/sdk/llm/__init__.py +45 -0
- openhands/sdk/llm/exceptions/__init__.py +45 -0
- openhands/sdk/llm/exceptions/classifier.py +50 -0
- openhands/sdk/llm/exceptions/mapping.py +54 -0
- openhands/sdk/llm/exceptions/types.py +101 -0
- openhands/sdk/llm/llm.py +1140 -0
- openhands/sdk/llm/llm_registry.py +122 -0
- openhands/sdk/llm/llm_response.py +59 -0
- openhands/sdk/llm/message.py +656 -0
- openhands/sdk/llm/mixins/fn_call_converter.py +1288 -0
- openhands/sdk/llm/mixins/non_native_fc.py +97 -0
- openhands/sdk/llm/options/__init__.py +1 -0
- openhands/sdk/llm/options/chat_options.py +93 -0
- openhands/sdk/llm/options/common.py +19 -0
- openhands/sdk/llm/options/responses_options.py +67 -0
- openhands/sdk/llm/router/__init__.py +10 -0
- openhands/sdk/llm/router/base.py +117 -0
- openhands/sdk/llm/router/impl/multimodal.py +76 -0
- openhands/sdk/llm/router/impl/random.py +22 -0
- openhands/sdk/llm/streaming.py +9 -0
- openhands/sdk/llm/utils/metrics.py +312 -0
- openhands/sdk/llm/utils/model_features.py +192 -0
- openhands/sdk/llm/utils/model_info.py +90 -0
- openhands/sdk/llm/utils/model_prompt_spec.py +98 -0
- openhands/sdk/llm/utils/retry_mixin.py +128 -0
- openhands/sdk/llm/utils/telemetry.py +362 -0
- openhands/sdk/llm/utils/unverified_models.py +156 -0
- openhands/sdk/llm/utils/verified_models.py +65 -0
- openhands/sdk/logger/__init__.py +22 -0
- openhands/sdk/logger/logger.py +195 -0
- openhands/sdk/logger/rolling.py +113 -0
- openhands/sdk/mcp/__init__.py +24 -0
- openhands/sdk/mcp/client.py +76 -0
- openhands/sdk/mcp/definition.py +106 -0
- openhands/sdk/mcp/exceptions.py +19 -0
- openhands/sdk/mcp/tool.py +270 -0
- openhands/sdk/mcp/utils.py +83 -0
- openhands/sdk/observability/__init__.py +4 -0
- openhands/sdk/observability/laminar.py +166 -0
- openhands/sdk/observability/utils.py +20 -0
- openhands/sdk/py.typed +0 -0
- openhands/sdk/secret/__init__.py +19 -0
- openhands/sdk/secret/secrets.py +92 -0
- openhands/sdk/security/__init__.py +6 -0
- openhands/sdk/security/analyzer.py +111 -0
- openhands/sdk/security/confirmation_policy.py +61 -0
- openhands/sdk/security/llm_analyzer.py +29 -0
- openhands/sdk/security/risk.py +100 -0
- openhands/sdk/tool/__init__.py +34 -0
- openhands/sdk/tool/builtins/__init__.py +34 -0
- openhands/sdk/tool/builtins/finish.py +106 -0
- openhands/sdk/tool/builtins/think.py +117 -0
- openhands/sdk/tool/registry.py +184 -0
- openhands/sdk/tool/schema.py +286 -0
- openhands/sdk/tool/spec.py +39 -0
- openhands/sdk/tool/tool.py +481 -0
- openhands/sdk/utils/__init__.py +22 -0
- openhands/sdk/utils/async_executor.py +115 -0
- openhands/sdk/utils/async_utils.py +39 -0
- openhands/sdk/utils/cipher.py +68 -0
- openhands/sdk/utils/command.py +90 -0
- openhands/sdk/utils/deprecation.py +166 -0
- openhands/sdk/utils/github.py +44 -0
- openhands/sdk/utils/json.py +48 -0
- openhands/sdk/utils/models.py +570 -0
- openhands/sdk/utils/paging.py +63 -0
- openhands/sdk/utils/pydantic_diff.py +85 -0
- openhands/sdk/utils/pydantic_secrets.py +64 -0
- openhands/sdk/utils/truncate.py +117 -0
- openhands/sdk/utils/visualize.py +58 -0
- openhands/sdk/workspace/__init__.py +17 -0
- openhands/sdk/workspace/base.py +158 -0
- openhands/sdk/workspace/local.py +189 -0
- openhands/sdk/workspace/models.py +35 -0
- openhands/sdk/workspace/remote/__init__.py +8 -0
- openhands/sdk/workspace/remote/async_remote_workspace.py +149 -0
- openhands/sdk/workspace/remote/base.py +164 -0
- openhands/sdk/workspace/remote/remote_workspace_mixin.py +323 -0
- openhands/sdk/workspace/workspace.py +49 -0
- openhands_sdk-1.7.3.dist-info/METADATA +17 -0
- openhands_sdk-1.7.3.dist-info/RECORD +180 -0
- openhands_sdk-1.7.3.dist-info/WHEEL +5 -0
- openhands_sdk-1.7.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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,30 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OpenHands Hooks System - Event-driven hooks for automation and control.
|
|
3
|
+
|
|
4
|
+
Hooks are event-driven scripts that execute at specific lifecycle events
|
|
5
|
+
during agent execution, enabling deterministic control over agent behavior.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from openhands.sdk.hooks.config import HookConfig, HookDefinition, HookMatcher
|
|
9
|
+
from openhands.sdk.hooks.conversation_hooks import (
|
|
10
|
+
HookEventProcessor,
|
|
11
|
+
create_hook_callback,
|
|
12
|
+
)
|
|
13
|
+
from openhands.sdk.hooks.executor import HookExecutor, HookResult
|
|
14
|
+
from openhands.sdk.hooks.manager import HookManager
|
|
15
|
+
from openhands.sdk.hooks.types import HookDecision, HookEvent, HookEventType
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"HookConfig",
|
|
20
|
+
"HookDefinition",
|
|
21
|
+
"HookMatcher",
|
|
22
|
+
"HookExecutor",
|
|
23
|
+
"HookResult",
|
|
24
|
+
"HookManager",
|
|
25
|
+
"HookEvent",
|
|
26
|
+
"HookEventType",
|
|
27
|
+
"HookDecision",
|
|
28
|
+
"HookEventProcessor",
|
|
29
|
+
"create_hook_callback",
|
|
30
|
+
]
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Hook configuration loading and management."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
|
|
12
|
+
from openhands.sdk.hooks.types import HookEventType
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class HookType(str, Enum):
|
|
19
|
+
"""Types of hooks that can be executed."""
|
|
20
|
+
|
|
21
|
+
COMMAND = "command" # Shell command executed via subprocess
|
|
22
|
+
PROMPT = "prompt" # LLM-based evaluation (future)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class HookDefinition(BaseModel):
|
|
26
|
+
"""A single hook definition."""
|
|
27
|
+
|
|
28
|
+
type: HookType = HookType.COMMAND
|
|
29
|
+
command: str
|
|
30
|
+
timeout: int = 60
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class HookMatcher(BaseModel):
|
|
34
|
+
"""Matches events to hooks based on patterns.
|
|
35
|
+
|
|
36
|
+
Supports exact match, wildcard (*), and regex (auto-detected or /pattern/).
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
matcher: str = "*"
|
|
40
|
+
hooks: list[HookDefinition] = Field(default_factory=list)
|
|
41
|
+
|
|
42
|
+
# Regex metacharacters that indicate a pattern should be treated as regex
|
|
43
|
+
_REGEX_METACHARACTERS = set("|.*+?[]()^$\\")
|
|
44
|
+
|
|
45
|
+
def matches(self, tool_name: str | None) -> bool:
|
|
46
|
+
"""Check if this matcher matches the given tool name."""
|
|
47
|
+
# Wildcard matches everything
|
|
48
|
+
if self.matcher == "*" or self.matcher == "":
|
|
49
|
+
return True
|
|
50
|
+
|
|
51
|
+
if tool_name is None:
|
|
52
|
+
return self.matcher in ("*", "")
|
|
53
|
+
|
|
54
|
+
# Check for explicit regex pattern (enclosed in /)
|
|
55
|
+
is_regex = (
|
|
56
|
+
self.matcher.startswith("/")
|
|
57
|
+
and self.matcher.endswith("/")
|
|
58
|
+
and len(self.matcher) > 2
|
|
59
|
+
)
|
|
60
|
+
if is_regex:
|
|
61
|
+
pattern = self.matcher[1:-1]
|
|
62
|
+
try:
|
|
63
|
+
return bool(re.fullmatch(pattern, tool_name))
|
|
64
|
+
except re.error:
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
# Auto-detect regex: if matcher contains metacharacters, treat as regex
|
|
68
|
+
if any(c in self.matcher for c in self._REGEX_METACHARACTERS):
|
|
69
|
+
try:
|
|
70
|
+
return bool(re.fullmatch(self.matcher, tool_name))
|
|
71
|
+
except re.error:
|
|
72
|
+
# Invalid regex, fall through to exact match
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
# Exact match
|
|
76
|
+
return self.matcher == tool_name
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class HookConfig(BaseModel):
|
|
80
|
+
"""Configuration for all hooks, loaded from .openhands/hooks.json."""
|
|
81
|
+
|
|
82
|
+
hooks: dict[str, list[HookMatcher]] = Field(default_factory=dict)
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def load(
|
|
86
|
+
cls, path: str | Path | None = None, working_dir: str | Path | None = None
|
|
87
|
+
) -> "HookConfig":
|
|
88
|
+
"""Load config from path or search .openhands/hooks.json locations.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
path: Explicit path to hooks.json file. If provided, working_dir is ignored.
|
|
92
|
+
working_dir: Project directory for discovering .openhands/hooks.json.
|
|
93
|
+
Falls back to cwd if not provided.
|
|
94
|
+
"""
|
|
95
|
+
if path is None:
|
|
96
|
+
# Search for hooks.json in standard locations
|
|
97
|
+
base_dir = Path(working_dir) if working_dir else Path.cwd()
|
|
98
|
+
search_paths = [
|
|
99
|
+
base_dir / ".openhands" / "hooks.json",
|
|
100
|
+
Path.home() / ".openhands" / "hooks.json",
|
|
101
|
+
]
|
|
102
|
+
for search_path in search_paths:
|
|
103
|
+
if search_path.exists():
|
|
104
|
+
path = search_path
|
|
105
|
+
break
|
|
106
|
+
|
|
107
|
+
if path is None:
|
|
108
|
+
return cls()
|
|
109
|
+
|
|
110
|
+
path = Path(path)
|
|
111
|
+
if not path.exists():
|
|
112
|
+
return cls()
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
with open(path) as f:
|
|
116
|
+
data = json.load(f)
|
|
117
|
+
return cls.from_dict(data)
|
|
118
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
119
|
+
# Log warning but don't fail - just return empty config
|
|
120
|
+
logger.warning(f"Failed to load hooks from {path}: {e}")
|
|
121
|
+
return cls()
|
|
122
|
+
|
|
123
|
+
@classmethod
|
|
124
|
+
def from_dict(cls, data: dict[str, Any]) -> "HookConfig":
|
|
125
|
+
"""Create HookConfig from a dictionary."""
|
|
126
|
+
hooks_data = data.get("hooks", {})
|
|
127
|
+
hooks: dict[str, list[HookMatcher]] = {}
|
|
128
|
+
|
|
129
|
+
for event_type, matchers in hooks_data.items():
|
|
130
|
+
if not isinstance(matchers, list):
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
hooks[event_type] = []
|
|
134
|
+
for matcher_data in matchers:
|
|
135
|
+
if isinstance(matcher_data, dict):
|
|
136
|
+
# Parse hooks within the matcher
|
|
137
|
+
hook_defs = []
|
|
138
|
+
for hook_data in matcher_data.get("hooks", []):
|
|
139
|
+
if isinstance(hook_data, dict):
|
|
140
|
+
hook_defs.append(HookDefinition(**hook_data))
|
|
141
|
+
|
|
142
|
+
hooks[event_type].append(
|
|
143
|
+
HookMatcher(
|
|
144
|
+
matcher=matcher_data.get("matcher", "*"),
|
|
145
|
+
hooks=hook_defs,
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
return cls(hooks=hooks)
|
|
150
|
+
|
|
151
|
+
def get_hooks_for_event(
|
|
152
|
+
self, event_type: HookEventType, tool_name: str | None = None
|
|
153
|
+
) -> list[HookDefinition]:
|
|
154
|
+
"""Get all hooks that should run for an event."""
|
|
155
|
+
event_key = event_type.value
|
|
156
|
+
matchers = self.hooks.get(event_key, [])
|
|
157
|
+
|
|
158
|
+
result: list[HookDefinition] = []
|
|
159
|
+
for matcher in matchers:
|
|
160
|
+
if matcher.matches(tool_name):
|
|
161
|
+
result.extend(matcher.hooks)
|
|
162
|
+
|
|
163
|
+
return result
|
|
164
|
+
|
|
165
|
+
def has_hooks_for_event(self, event_type: HookEventType) -> bool:
|
|
166
|
+
"""Check if there are any hooks configured for an event type."""
|
|
167
|
+
return event_type.value in self.hooks and len(self.hooks[event_type.value]) > 0
|
|
168
|
+
|
|
169
|
+
def to_dict(self) -> dict[str, Any]:
|
|
170
|
+
"""Convert to dictionary format for serialization."""
|
|
171
|
+
hooks_dict = {k: [m.model_dump() for m in v] for k, v in self.hooks.items()}
|
|
172
|
+
return {"hooks": hooks_dict}
|
|
173
|
+
|
|
174
|
+
def save(self, path: str | Path) -> None:
|
|
175
|
+
"""Save hook configuration to a JSON file."""
|
|
176
|
+
path = Path(path)
|
|
177
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
178
|
+
|
|
179
|
+
with open(path, "w") as f:
|
|
180
|
+
json.dump(self.to_dict(), f, indent=2)
|