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,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)