sky-dev 0.0.5__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.
- sky/__init__.py +6 -0
- sky/cli.py +833 -0
- sky/config/__init__.py +19 -0
- sky/config/models.yaml +39 -0
- sky/config/schema.py +275 -0
- sky/core/__init__.py +48 -0
- sky/core/approval.py +92 -0
- sky/core/benchmark.py +39 -0
- sky/core/chat.py +211 -0
- sky/core/fast_loop.py +330 -0
- sky/core/mode_prompts.py +106 -0
- sky/core/router.py +264 -0
- sky/core/subagent.py +302 -0
- sky/core/workflow.py +662 -0
- sky/errors.py +29 -0
- sky/memory/__init__.py +27 -0
- sky/memory/indexer.py +450 -0
- sky/memory/vectorstore.py +271 -0
- sky/security/__init__.py +17 -0
- sky/security/audit.py +23 -0
- sky/security/detection.py +94 -0
- sky/security/guardrails.py +106 -0
- sky/security/prompts.py +58 -0
- sky/security/rate_limit.py +36 -0
- sky/security/sanitize.py +152 -0
- sky/storage/__init__.py +21 -0
- sky/storage/db.py +492 -0
- sky/tools/__init__.py +26 -0
- sky/tools/fs_tools.py +139 -0
- sky/tools/git_tools.py +139 -0
- sky/tools/registry.py +219 -0
- sky/tools/search_tools.py +128 -0
- sky/tools/shell_tools.py +73 -0
- sky_dev-0.0.5.dist-info/METADATA +119 -0
- sky_dev-0.0.5.dist-info/RECORD +39 -0
- sky_dev-0.0.5.dist-info/WHEEL +5 -0
- sky_dev-0.0.5.dist-info/entry_points.txt +2 -0
- sky_dev-0.0.5.dist-info/licenses/LICENSE +21 -0
- sky_dev-0.0.5.dist-info/top_level.txt +1 -0
sky/tools/fs_tools.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Filesystem manipulation tools."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tempfile
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Dict, Optional
|
|
7
|
+
|
|
8
|
+
import patch
|
|
9
|
+
|
|
10
|
+
from sky.tools.registry import RiskTier, register_tool
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _resolve_and_validate_path(path_str: str) -> Path:
|
|
14
|
+
"""Resolve a path and ensure it stays within the current working directory."""
|
|
15
|
+
cwd = Path.cwd().resolve()
|
|
16
|
+
target_path = Path(path_str).expanduser().resolve()
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
# Check if the target_path is relative to cwd (prevents traversal)
|
|
20
|
+
target_path.relative_to(cwd)
|
|
21
|
+
except ValueError:
|
|
22
|
+
raise PermissionError(f"Path traversal attempt blocked: {path_str} resolves outside of project root {cwd}")
|
|
23
|
+
|
|
24
|
+
return target_path
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@register_tool("read_file", "Read content from a file safely.", RiskTier.SAFE)
|
|
28
|
+
def read_file(path: str, offset: Optional[int] = None, limit: Optional[int] = None) -> str:
|
|
29
|
+
"""Read contents of a file with optional line ranges.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
path: Path to the file to read.
|
|
33
|
+
offset: Optional starting line number (1-indexed).
|
|
34
|
+
limit: Optional maximum number of lines to read.
|
|
35
|
+
"""
|
|
36
|
+
target = _resolve_and_validate_path(path)
|
|
37
|
+
if not target.exists() or not target.is_file():
|
|
38
|
+
raise FileNotFoundError(f"File not found: {path}")
|
|
39
|
+
|
|
40
|
+
with open(target, "r", encoding="utf-8", errors="replace") as f:
|
|
41
|
+
lines = f.readlines()
|
|
42
|
+
|
|
43
|
+
if offset is not None:
|
|
44
|
+
if offset < 1:
|
|
45
|
+
raise ValueError("Offset must be >= 1 (1-indexed).")
|
|
46
|
+
lines = lines[offset - 1:]
|
|
47
|
+
|
|
48
|
+
if limit is not None:
|
|
49
|
+
if limit < 1:
|
|
50
|
+
raise ValueError("Limit must be >= 1.")
|
|
51
|
+
lines = lines[:limit]
|
|
52
|
+
|
|
53
|
+
return "".join(lines)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@register_tool("write_file", "Write content to a file atomically.", RiskTier.DESTRUCTIVE)
|
|
57
|
+
def write_file(path: str, content: str) -> Dict[str, Any]:
|
|
58
|
+
"""Write complete content to a file.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
path: Path to the file to write.
|
|
62
|
+
content: The complete content to write.
|
|
63
|
+
"""
|
|
64
|
+
target = _resolve_and_validate_path(path)
|
|
65
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
|
|
67
|
+
fd, temp_path = tempfile.mkstemp(dir=target.parent, prefix=".tmp_sky_")
|
|
68
|
+
try:
|
|
69
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
70
|
+
f.write(content)
|
|
71
|
+
os.replace(temp_path, target)
|
|
72
|
+
except Exception as e:
|
|
73
|
+
if os.path.exists(temp_path):
|
|
74
|
+
os.remove(temp_path)
|
|
75
|
+
raise OSError(f"Failed to write file atomically: {e}")
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
"path": str(target.relative_to(Path.cwd())),
|
|
79
|
+
"bytes_written": len(content.encode("utf-8")),
|
|
80
|
+
"success": True
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@register_tool("edit_file", "Apply a unified diff to a file.", RiskTier.DESTRUCTIVE)
|
|
85
|
+
def edit_file(path: str, diff: str) -> Dict[str, Any]:
|
|
86
|
+
"""Edit a file by applying a unified diff patch.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
path: Path to the file to edit.
|
|
90
|
+
diff: The unified diff string to apply.
|
|
91
|
+
"""
|
|
92
|
+
target = _resolve_and_validate_path(path)
|
|
93
|
+
if not target.exists():
|
|
94
|
+
raise FileNotFoundError(f"Cannot edit non-existent file: {path}")
|
|
95
|
+
|
|
96
|
+
patch_set = patch.fromstring(diff.encode("utf-8"))
|
|
97
|
+
if not patch_set or not patch_set.items:
|
|
98
|
+
raise ValueError("Invalid unified diff provided.")
|
|
99
|
+
|
|
100
|
+
if len(patch_set.items) > 1:
|
|
101
|
+
raise ValueError("Diff contains multiple files. Use edit_file for one file at a time.")
|
|
102
|
+
|
|
103
|
+
import shutil
|
|
104
|
+
with tempfile.TemporaryDirectory(dir=target.parent) as temp_dir:
|
|
105
|
+
temp_dir_path = Path(temp_dir)
|
|
106
|
+
temp_target = temp_dir_path / target.name
|
|
107
|
+
|
|
108
|
+
# Copy original to temp
|
|
109
|
+
shutil.copy2(target, temp_target)
|
|
110
|
+
|
|
111
|
+
# Ensure the patch set uses the basename so it patches the file inside temp_dir
|
|
112
|
+
p = patch_set.items[0]
|
|
113
|
+
p.source = target.name.encode("utf-8")
|
|
114
|
+
p.target = target.name.encode("utf-8")
|
|
115
|
+
|
|
116
|
+
# Apply patch in temp directory
|
|
117
|
+
success = patch_set.apply(strip=0, root=str(temp_dir_path))
|
|
118
|
+
if not success:
|
|
119
|
+
raise RuntimeError("Failed to apply diff patch cleanly.")
|
|
120
|
+
|
|
121
|
+
# Atomic replace
|
|
122
|
+
os.replace(temp_target, target)
|
|
123
|
+
|
|
124
|
+
added = 0
|
|
125
|
+
removed = 0
|
|
126
|
+
for item in patch_set.items:
|
|
127
|
+
for hunk in item.hunks:
|
|
128
|
+
for line in hunk.text:
|
|
129
|
+
if line.startswith(b"+") and not line.startswith(b"+++"):
|
|
130
|
+
added += 1
|
|
131
|
+
elif line.startswith(b"-") and not line.startswith(b"---"):
|
|
132
|
+
removed += 1
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
"path": str(target.relative_to(Path.cwd())),
|
|
136
|
+
"lines_added": added,
|
|
137
|
+
"lines_removed": removed,
|
|
138
|
+
"success": True
|
|
139
|
+
}
|
sky/tools/git_tools.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Git integration tools."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
from sky.tools.registry import RiskTier, register_tool
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _run_git(args: List[str]) -> subprocess.CompletedProcess:
|
|
11
|
+
"""Helper to run git commands safely."""
|
|
12
|
+
try:
|
|
13
|
+
return subprocess.run(["git"] + args, capture_output=True, text=True, check=True)
|
|
14
|
+
except subprocess.CalledProcessError as e:
|
|
15
|
+
raise RuntimeError(f"Git command failed: {e.stderr}")
|
|
16
|
+
except FileNotFoundError:
|
|
17
|
+
raise RuntimeError("Git executable not found in PATH.")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@register_tool("git_diff", "Get git diffs for staged and unstaged changes.", RiskTier.SAFE)
|
|
21
|
+
def git_diff() -> Dict[str, Any]:
|
|
22
|
+
"""Get current git diff (unstaged and staged changes)."""
|
|
23
|
+
unstaged = subprocess.run(["git", "diff"], capture_output=True, text=True).stdout
|
|
24
|
+
staged = subprocess.run(["git", "diff", "--staged"], capture_output=True, text=True).stdout
|
|
25
|
+
|
|
26
|
+
f_changed = subprocess.run(["git", "diff", "--name-only"], capture_output=True, text=True).stdout.splitlines()
|
|
27
|
+
f_staged = subprocess.run(["git", "diff", "--staged", "--name-only"], capture_output=True, text=True).stdout.splitlines()
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
"unstaged": unstaged,
|
|
31
|
+
"staged": staged,
|
|
32
|
+
"files_changed": [f for f in f_changed if f],
|
|
33
|
+
"files_staged": [f for f in f_staged if f],
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@register_tool("git_log", "Get recent git commit history.", RiskTier.SAFE)
|
|
38
|
+
def git_log(limit: int = 10) -> List[Dict[str, Any]]:
|
|
39
|
+
"""Get git log history.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
limit: Number of commits to return.
|
|
43
|
+
"""
|
|
44
|
+
fmt = "%H|%an|%ae|%at|%s"
|
|
45
|
+
try:
|
|
46
|
+
result = _run_git(["log", f"--pretty=format:{fmt}", f"-n{limit}"])
|
|
47
|
+
except RuntimeError:
|
|
48
|
+
return []
|
|
49
|
+
|
|
50
|
+
commits = []
|
|
51
|
+
for line in result.stdout.splitlines():
|
|
52
|
+
if not line:
|
|
53
|
+
continue
|
|
54
|
+
parts = line.split("|", 4)
|
|
55
|
+
if len(parts) == 5:
|
|
56
|
+
commits.append({
|
|
57
|
+
"hash": parts[0],
|
|
58
|
+
"author_name": parts[1],
|
|
59
|
+
"author_email": parts[2],
|
|
60
|
+
"timestamp": parts[3],
|
|
61
|
+
"subject": parts[4]
|
|
62
|
+
})
|
|
63
|
+
return commits
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@register_tool("git_commit", "Commit changes to git repository.", RiskTier.DESTRUCTIVE)
|
|
67
|
+
def git_commit(message: str, files: Optional[List[str]] = None) -> Dict[str, Any]:
|
|
68
|
+
"""Commit files to the repository.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
message: Commit message.
|
|
72
|
+
files: Optional list of files to commit. If None, commits currently staged changes.
|
|
73
|
+
"""
|
|
74
|
+
if files:
|
|
75
|
+
_run_git(["add"] + files)
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
_run_git(["commit", "-m", message])
|
|
79
|
+
hash_result = _run_git(["rev-parse", "HEAD"])
|
|
80
|
+
return {
|
|
81
|
+
"hash": hash_result.stdout.strip(),
|
|
82
|
+
"message": message,
|
|
83
|
+
"files_committed": files or [],
|
|
84
|
+
"success": True
|
|
85
|
+
}
|
|
86
|
+
except RuntimeError as e:
|
|
87
|
+
return {
|
|
88
|
+
"hash": "",
|
|
89
|
+
"message": message,
|
|
90
|
+
"files_committed": [],
|
|
91
|
+
"success": False,
|
|
92
|
+
"error": str(e)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@register_tool("git_branch", "Create, switch, or view branches.", RiskTier.DESTRUCTIVE)
|
|
97
|
+
def git_branch(name: str, checkout: bool = False) -> Dict[str, Any]:
|
|
98
|
+
"""Create or checkout a git branch.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
name: Name of the branch.
|
|
102
|
+
checkout: Whether to switch to the branch.
|
|
103
|
+
"""
|
|
104
|
+
if not os.path.exists(".git"):
|
|
105
|
+
raise RuntimeError("Not a git repository (no .git directory found).")
|
|
106
|
+
|
|
107
|
+
branches = _run_git(["branch", "--list", name]).stdout.strip()
|
|
108
|
+
branch_exists = bool(branches)
|
|
109
|
+
|
|
110
|
+
created = False
|
|
111
|
+
switched = False
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
if checkout:
|
|
115
|
+
if branch_exists:
|
|
116
|
+
_run_git(["checkout", name])
|
|
117
|
+
else:
|
|
118
|
+
_run_git(["checkout", "-b", name])
|
|
119
|
+
created = True
|
|
120
|
+
switched = True
|
|
121
|
+
else:
|
|
122
|
+
if not branch_exists:
|
|
123
|
+
_run_git(["branch", name])
|
|
124
|
+
created = True
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
"branch": name,
|
|
128
|
+
"created": created,
|
|
129
|
+
"switched": switched,
|
|
130
|
+
"success": True
|
|
131
|
+
}
|
|
132
|
+
except RuntimeError as e:
|
|
133
|
+
return {
|
|
134
|
+
"branch": name,
|
|
135
|
+
"created": False,
|
|
136
|
+
"switched": False,
|
|
137
|
+
"success": False,
|
|
138
|
+
"error": str(e)
|
|
139
|
+
}
|
sky/tools/registry.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Tool registry for defining and managing agent capabilities."""
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any, Callable, Dict, List, Optional, get_args, get_origin
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RiskTier(str, Enum):
|
|
11
|
+
"""Classification of tool execution risk."""
|
|
12
|
+
|
|
13
|
+
SAFE = "safe"
|
|
14
|
+
DESTRUCTIVE = "destructive"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class ToolDefinition:
|
|
19
|
+
"""Definition of an agent tool."""
|
|
20
|
+
|
|
21
|
+
name: str
|
|
22
|
+
description: str
|
|
23
|
+
risk_tier: RiskTier
|
|
24
|
+
func: Callable[..., Any]
|
|
25
|
+
args_schema: Dict[str, Any]
|
|
26
|
+
required_args: List[str] = field(default_factory=list)
|
|
27
|
+
|
|
28
|
+
def to_openai_schema(self) -> Dict[str, Any]:
|
|
29
|
+
"""Convert the tool definition to OpenAI's function calling schema."""
|
|
30
|
+
return {
|
|
31
|
+
"type": "function",
|
|
32
|
+
"function": {
|
|
33
|
+
"name": self.name,
|
|
34
|
+
"description": self.description,
|
|
35
|
+
"parameters": {
|
|
36
|
+
"type": "object",
|
|
37
|
+
"properties": self.args_schema,
|
|
38
|
+
"required": self.required_args,
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ToolRegistry:
|
|
45
|
+
"""Singleton registry for all available tools."""
|
|
46
|
+
|
|
47
|
+
_instance: Optional["ToolRegistry"] = None
|
|
48
|
+
_tools: Dict[str, ToolDefinition]
|
|
49
|
+
|
|
50
|
+
def __new__(cls) -> "ToolRegistry":
|
|
51
|
+
if cls._instance is None:
|
|
52
|
+
cls._instance = super(ToolRegistry, cls).__new__(cls)
|
|
53
|
+
cls._instance._tools = {}
|
|
54
|
+
return cls._instance
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def _type_to_json_schema(cls, type_hint: Any) -> str:
|
|
58
|
+
"""Convert a Python type hint to a JSON Schema type string."""
|
|
59
|
+
if type_hint == str:
|
|
60
|
+
return "string"
|
|
61
|
+
elif type_hint == int:
|
|
62
|
+
return "integer"
|
|
63
|
+
elif type_hint == float:
|
|
64
|
+
return "number"
|
|
65
|
+
elif type_hint == bool:
|
|
66
|
+
return "boolean"
|
|
67
|
+
elif type_hint == dict or type_hint == Dict:
|
|
68
|
+
return "object"
|
|
69
|
+
elif type_hint == list or type_hint == List:
|
|
70
|
+
return "array"
|
|
71
|
+
elif type_hint == Any:
|
|
72
|
+
return "string" # Fallback for Any
|
|
73
|
+
|
|
74
|
+
origin = get_origin(type_hint)
|
|
75
|
+
if origin is not None:
|
|
76
|
+
# Handle Optional[...] which is Union[..., NoneType]
|
|
77
|
+
import typing
|
|
78
|
+
is_union = False
|
|
79
|
+
try:
|
|
80
|
+
import types
|
|
81
|
+
if hasattr(types, "UnionType") and isinstance(type_hint, types.UnionType):
|
|
82
|
+
is_union = True
|
|
83
|
+
except ImportError:
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
if origin is typing.Union or is_union or type_hint.__class__.__name__ in ('_UnionGenericAlias', 'UnionType', 'Union') or "Union" in str(type_hint):
|
|
87
|
+
args = get_args(type_hint)
|
|
88
|
+
# Find the non-None type
|
|
89
|
+
for arg in args:
|
|
90
|
+
if arg is not type(None):
|
|
91
|
+
return cls._type_to_json_schema(arg)
|
|
92
|
+
|
|
93
|
+
# Handle List, Dict generics
|
|
94
|
+
if origin == list or origin == typing.List or str(origin).endswith("list"):
|
|
95
|
+
return "array"
|
|
96
|
+
if origin == dict or origin == typing.Dict or str(origin).endswith("dict"):
|
|
97
|
+
return "object"
|
|
98
|
+
|
|
99
|
+
return "string" # Default fallback
|
|
100
|
+
|
|
101
|
+
@classmethod
|
|
102
|
+
def _extract_param_description(cls, docstring: Optional[str], param_name: str) -> Optional[str]:
|
|
103
|
+
"""Extract parameter description from the docstring Args: section."""
|
|
104
|
+
if not docstring:
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
# Look for 'Args:' or 'Parameters:' section
|
|
108
|
+
args_match = re.search(r"(?:Args|Parameters):\s*\n(.*?)(?:\n\s*\n|\Z)", docstring, re.DOTALL)
|
|
109
|
+
if not args_match:
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
args_section = args_match.group(1)
|
|
113
|
+
|
|
114
|
+
# Look for the specific parameter description
|
|
115
|
+
param_pattern = re.compile(rf"^\s*{param_name}\s*(?:\([^)]+\))?\s*:\s*(.*?)(?=\n\s*\w+\s*(?:\([^)]+\))?\s*:|\Z)", re.DOTALL | re.MULTILINE)
|
|
116
|
+
|
|
117
|
+
param_match = param_pattern.search(args_section)
|
|
118
|
+
if param_match:
|
|
119
|
+
desc = param_match.group(1).replace("\n", " ").strip()
|
|
120
|
+
return re.sub(r"\s+", " ", desc)
|
|
121
|
+
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
def register(self, name: str, description: str, risk_tier: RiskTier) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
125
|
+
"""Decorator to register a function as a tool."""
|
|
126
|
+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
127
|
+
sig = inspect.signature(func)
|
|
128
|
+
docstring = inspect.getdoc(func)
|
|
129
|
+
|
|
130
|
+
args_schema: Dict[str, Any] = {}
|
|
131
|
+
required_args: List[str] = []
|
|
132
|
+
|
|
133
|
+
for param_name, param in sig.parameters.items():
|
|
134
|
+
if param_name == "self" or param_name == "cls":
|
|
135
|
+
continue
|
|
136
|
+
|
|
137
|
+
json_type = self._type_to_json_schema(param.annotation)
|
|
138
|
+
param_desc = self._extract_param_description(docstring, param_name)
|
|
139
|
+
|
|
140
|
+
param_schema: Dict[str, Any] = {"type": json_type}
|
|
141
|
+
if param_desc:
|
|
142
|
+
param_schema["description"] = param_desc
|
|
143
|
+
|
|
144
|
+
args_schema[param_name] = param_schema
|
|
145
|
+
|
|
146
|
+
if param.default == inspect.Parameter.empty:
|
|
147
|
+
required_args.append(param_name)
|
|
148
|
+
|
|
149
|
+
tool_def = ToolDefinition(
|
|
150
|
+
name=name,
|
|
151
|
+
description=description,
|
|
152
|
+
risk_tier=risk_tier,
|
|
153
|
+
func=func,
|
|
154
|
+
args_schema=args_schema,
|
|
155
|
+
required_args=required_args,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
self._tools[name] = tool_def
|
|
159
|
+
|
|
160
|
+
import os
|
|
161
|
+
if os.getenv("SKY_VERBOSE", "").lower() == "true":
|
|
162
|
+
from rich.console import Console
|
|
163
|
+
import json
|
|
164
|
+
Console().print(f"[dim]Schema for {name}: {json.dumps(tool_def.to_openai_schema(), indent=2)}[/dim]")
|
|
165
|
+
|
|
166
|
+
return func
|
|
167
|
+
|
|
168
|
+
return decorator
|
|
169
|
+
|
|
170
|
+
def get(self, name: str) -> Optional[ToolDefinition]:
|
|
171
|
+
"""Get a tool definition by name."""
|
|
172
|
+
return self._tools.get(name)
|
|
173
|
+
|
|
174
|
+
def list(self, risk_tier: Optional[RiskTier] = None) -> List[ToolDefinition]:
|
|
175
|
+
"""List all tools, optionally filtered by risk tier."""
|
|
176
|
+
if risk_tier:
|
|
177
|
+
return [t for t in self._tools.values() if t.risk_tier == risk_tier]
|
|
178
|
+
return list(self._tools.values())
|
|
179
|
+
|
|
180
|
+
def get_schemas(self, risk_tier: Optional[RiskTier] = None) -> List[Dict[str, Any]]:
|
|
181
|
+
"""Get OpenAI function schemas for all tools, optionally filtered."""
|
|
182
|
+
tools = self.list(risk_tier)
|
|
183
|
+
return [t.to_openai_schema() for t in tools]
|
|
184
|
+
|
|
185
|
+
def execute(self, name: str, **kwargs: Any) -> Any:
|
|
186
|
+
"""Execute a tool by name with the given arguments."""
|
|
187
|
+
tool = self.get(name)
|
|
188
|
+
if not tool:
|
|
189
|
+
raise ValueError(f"Tool not found: {name}")
|
|
190
|
+
return tool.func(**kwargs)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# Global registry instance and helper functions
|
|
194
|
+
_registry = ToolRegistry()
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def register_tool(name: str, description: str, risk_tier: RiskTier) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
198
|
+
"""Register a tool with the global registry."""
|
|
199
|
+
return _registry.register(name, description, risk_tier)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def get_tool(name: str) -> Optional[ToolDefinition]:
|
|
203
|
+
"""Get a tool from the global registry."""
|
|
204
|
+
return _registry.get(name)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def list_tools(risk_tier: Optional[RiskTier] = None) -> List[ToolDefinition]:
|
|
208
|
+
"""List tools from the global registry."""
|
|
209
|
+
return _registry.list(risk_tier)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def get_tools_by_risk(risk_tier: RiskTier) -> List[ToolDefinition]:
|
|
213
|
+
"""Get tools matching a specific risk tier."""
|
|
214
|
+
return _registry.list(risk_tier)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def get_tool_schemas(risk_tier: Optional[RiskTier] = None) -> List[Dict[str, Any]]:
|
|
218
|
+
"""Get OpenAI schemas from the global registry."""
|
|
219
|
+
return _registry.get_schemas(risk_tier)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Search tools utilizing ripgrep."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
from sky.tools.registry import RiskTier, register_tool
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@register_tool("grep", "Search for a pattern in files using ripgrep.", RiskTier.SAFE)
|
|
12
|
+
def grep(pattern: str, path: Optional[str] = None, glob_filter: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
13
|
+
"""Search for text patterns using ripgrep.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
pattern: The regex pattern to search for.
|
|
17
|
+
path: Optional specific path or directory to search in.
|
|
18
|
+
glob_filter: Optional glob pattern to filter files (e.g. '*.py').
|
|
19
|
+
"""
|
|
20
|
+
cmd = ["rg", "--json", pattern]
|
|
21
|
+
if glob_filter:
|
|
22
|
+
cmd.extend(["-g", glob_filter])
|
|
23
|
+
if path:
|
|
24
|
+
cmd.append(path)
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
|
28
|
+
|
|
29
|
+
matches = []
|
|
30
|
+
for line in result.stdout.splitlines():
|
|
31
|
+
if not line.strip():
|
|
32
|
+
continue
|
|
33
|
+
try:
|
|
34
|
+
data = json.loads(line)
|
|
35
|
+
if data.get("type") == "match":
|
|
36
|
+
match_data = data.get("data", {})
|
|
37
|
+
matches.append({
|
|
38
|
+
"file": match_data.get("path", {}).get("text", ""),
|
|
39
|
+
"line_number": match_data.get("line_number"),
|
|
40
|
+
"line_text": match_data.get("lines", {}).get("text", ""),
|
|
41
|
+
"submatches": match_data.get("submatches", [])
|
|
42
|
+
})
|
|
43
|
+
except json.JSONDecodeError:
|
|
44
|
+
continue
|
|
45
|
+
|
|
46
|
+
return matches
|
|
47
|
+
except FileNotFoundError:
|
|
48
|
+
raise RuntimeError("rg (ripgrep) not found. Please install ripgrep.")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@register_tool("glob", "Find files matching a glob pattern.", RiskTier.SAFE)
|
|
52
|
+
def glob(pattern: str) -> List[str]:
|
|
53
|
+
"""Find files using a glob pattern, respecting .gitignore.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
pattern: Glob pattern to match files.
|
|
57
|
+
"""
|
|
58
|
+
try:
|
|
59
|
+
cmd = ["rg", "--files", "-g", pattern]
|
|
60
|
+
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
|
61
|
+
paths = [p for p in result.stdout.splitlines() if p.strip()]
|
|
62
|
+
return paths
|
|
63
|
+
except (FileNotFoundError, subprocess.CalledProcessError):
|
|
64
|
+
paths = []
|
|
65
|
+
cwd = Path.cwd()
|
|
66
|
+
exclude = {".git", "node_modules", "venv", "__pycache__", ".sky"}
|
|
67
|
+
|
|
68
|
+
for p in cwd.rglob(pattern):
|
|
69
|
+
if p.is_file():
|
|
70
|
+
if not any(part in exclude for part in p.parts):
|
|
71
|
+
try:
|
|
72
|
+
paths.append(str(p.relative_to(cwd)))
|
|
73
|
+
except ValueError:
|
|
74
|
+
pass
|
|
75
|
+
return paths
|
|
76
|
+
|
|
77
|
+
import logging
|
|
78
|
+
|
|
79
|
+
logger = logging.getLogger(__name__)
|
|
80
|
+
|
|
81
|
+
@register_tool(
|
|
82
|
+
name="search_codebase",
|
|
83
|
+
description="Semantically search the repository for relevant code snippets, functions, or concepts. Use this for high-level queries like 'where is Docker handled' or 'how does approval work'.",
|
|
84
|
+
risk_tier=RiskTier.SAFE
|
|
85
|
+
)
|
|
86
|
+
def search_codebase(query: str, top_k: int = 3) -> dict:
|
|
87
|
+
"""
|
|
88
|
+
Search the codebase semantically using vector embeddings.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
query: The search query or concept to find
|
|
92
|
+
top_k: Number of results to return (default: 3)
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
dict with 'results' list and 'total' count
|
|
96
|
+
"""
|
|
97
|
+
try:
|
|
98
|
+
from sky.memory.vectorstore import get_vector_store
|
|
99
|
+
# Get vector store
|
|
100
|
+
store = get_vector_store(Path.home() / ".sky" / "lancedb")
|
|
101
|
+
|
|
102
|
+
# Search
|
|
103
|
+
results = store.search(query, top_k=top_k)
|
|
104
|
+
|
|
105
|
+
# Format results
|
|
106
|
+
formatted_results = []
|
|
107
|
+
for r in results:
|
|
108
|
+
formatted_results.append({
|
|
109
|
+
"file_path": r["file_path"],
|
|
110
|
+
"content": r["content"][:500] + ("..." if len(r["content"]) > 500 else ""),
|
|
111
|
+
"score": round(1 - r["score"], 3), # Convert distance to similarity
|
|
112
|
+
"language": r.get("language", "text"),
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
"query": query,
|
|
117
|
+
"total": len(formatted_results),
|
|
118
|
+
"results": formatted_results,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
except Exception as e:
|
|
122
|
+
logger.error(f"Search codebase error: {e}")
|
|
123
|
+
return {
|
|
124
|
+
"query": query,
|
|
125
|
+
"total": 0,
|
|
126
|
+
"results": [],
|
|
127
|
+
"error": str(e),
|
|
128
|
+
}
|
sky/tools/shell_tools.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Shell execution tools with Docker sandboxing."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
from typing import Any, Dict
|
|
6
|
+
|
|
7
|
+
from sky.config import load_config
|
|
8
|
+
from sky.tools.registry import RiskTier, register_tool
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@register_tool("bash", "Execute a bash command in a sandboxed Docker container.", RiskTier.DESTRUCTIVE)
|
|
12
|
+
def bash(command: str, timeout: int = 60) -> Dict[str, Any]:
|
|
13
|
+
"""Run a bash command in a secure Docker sandbox.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
command: The bash command to execute.
|
|
17
|
+
timeout: Maximum execution time in seconds.
|
|
18
|
+
"""
|
|
19
|
+
config = load_config()
|
|
20
|
+
|
|
21
|
+
docker_image = config.docker_image
|
|
22
|
+
# HARD-BLOCK constraint
|
|
23
|
+
docker_network = "none"
|
|
24
|
+
|
|
25
|
+
workspace_dir = os.path.abspath(os.getcwd())
|
|
26
|
+
|
|
27
|
+
docker_cmd = [
|
|
28
|
+
"docker", "run", "--rm",
|
|
29
|
+
f"--network={docker_network}",
|
|
30
|
+
"--cap-drop=ALL",
|
|
31
|
+
"--user=1000:1000",
|
|
32
|
+
"-v", f"{workspace_dir}:/workspace:rw",
|
|
33
|
+
"-w", "/workspace",
|
|
34
|
+
docker_image,
|
|
35
|
+
"bash", "-c", command
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
result = subprocess.run(
|
|
40
|
+
docker_cmd,
|
|
41
|
+
capture_output=True,
|
|
42
|
+
text=True,
|
|
43
|
+
timeout=timeout,
|
|
44
|
+
check=False
|
|
45
|
+
)
|
|
46
|
+
return {
|
|
47
|
+
"stdout": result.stdout,
|
|
48
|
+
"stderr": result.stderr,
|
|
49
|
+
"exit_code": result.returncode,
|
|
50
|
+
"success": result.returncode == 0
|
|
51
|
+
}
|
|
52
|
+
except subprocess.TimeoutExpired as e:
|
|
53
|
+
return {
|
|
54
|
+
"stdout": e.stdout.decode() if isinstance(e.stdout, bytes) else (e.stdout or ""),
|
|
55
|
+
"stderr": e.stderr.decode() if isinstance(e.stderr, bytes) else (e.stderr or ""),
|
|
56
|
+
"exit_code": 124,
|
|
57
|
+
"success": False,
|
|
58
|
+
"error": "Timeout expired"
|
|
59
|
+
}
|
|
60
|
+
except FileNotFoundError:
|
|
61
|
+
return {
|
|
62
|
+
"stdout": "",
|
|
63
|
+
"stderr": "Docker executable not found. Ensure Docker is installed and in PATH.",
|
|
64
|
+
"exit_code": -1,
|
|
65
|
+
"success": False
|
|
66
|
+
}
|
|
67
|
+
except Exception as e:
|
|
68
|
+
return {
|
|
69
|
+
"stdout": "",
|
|
70
|
+
"stderr": str(e),
|
|
71
|
+
"exit_code": -1,
|
|
72
|
+
"success": False
|
|
73
|
+
}
|