relay-code 0.1.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.
tools/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ """Tools module for the Relay agent."""
2
+
3
+ from tools.base import Tool, ToolKind, ToolResult, ToolInvocation, ToolConfirmation
4
+ from tools.registry import ToolRegistry, create_default_registery
5
+
6
+ __all__ = [
7
+ 'Tool',
8
+ 'ToolKind',
9
+ 'ToolResult',
10
+ 'ToolInvocation',
11
+ 'ToolConfirmation',
12
+ 'ToolRegistry',
13
+ 'create_default_registery',
14
+ ]
tools/base.py ADDED
@@ -0,0 +1,188 @@
1
+ from __future__ import annotations
2
+ import abc
3
+ from pathlib import Path
4
+ from pydantic import BaseModel, ValidationError
5
+ from pydantic.json_schema import model_json_schema
6
+ from dataclasses import dataclass, field
7
+ from enum import Enum
8
+ from typing import Any
9
+
10
+ from config.config import Config
11
+
12
+ class ToolKind(str, Enum):
13
+ READ = "read"
14
+ WRITE = "write"
15
+ SHELL = "shell"
16
+ NETWORK = "network"
17
+ MEMORY = "memory"
18
+ MCP = "mcp"
19
+ GIT = "git"
20
+
21
+ @dataclass
22
+ class FileDiff:
23
+ path: Path
24
+ old_content: str
25
+ new_content: str
26
+
27
+ is_new_file: bool = False
28
+ is_deletion: bool = False
29
+
30
+ def create_diff(self) -> str:
31
+
32
+ import difflib
33
+
34
+ old_lines = self.old_content.splitlines(keepends=True)
35
+ new_lines = self.new_content.splitlines(keepends=True)
36
+
37
+ if old_lines and not old_lines[-1].endswith('\n'):
38
+ old_lines[-1] += '\n'
39
+ if new_lines and not new_lines[-1].endswith('\n'):
40
+ new_lines[-1] += '\n'
41
+
42
+ old_name = '/dev/null' if self.is_new_file else str(self.path)
43
+ new_name = '/dev/null' if self.is_deletion else str(self.path)
44
+
45
+ diff = difflib.unified_diff(
46
+ old_lines,
47
+ new_lines,
48
+ fromfile=old_name,
49
+ tofile=new_name,
50
+ )
51
+
52
+ return "".join(diff)
53
+
54
+ @dataclass
55
+ class ToolResult:
56
+ success: bool
57
+ output: str
58
+ error: str | None = None
59
+ metadata: dict[str, Any] = field(default_factory=dict)
60
+
61
+ # if files are long, we have to truncate.
62
+ truncated: bool = False
63
+ diff: FileDiff | None = None
64
+ exit_code: int | None = None
65
+
66
+ @classmethod
67
+ def error_result(
68
+ cls,
69
+ error: str,
70
+ output: str = "",
71
+ **kwargs: Any
72
+ ):
73
+ return cls(
74
+ success=False,
75
+ output=output,
76
+ error=error,
77
+ **kwargs,
78
+ )
79
+
80
+ @classmethod
81
+ def success_result(
82
+ cls,
83
+ output: str,
84
+ **kwargs: Any
85
+ ):
86
+ return cls(
87
+ success=True,
88
+ output=output,
89
+ error=None,
90
+ **kwargs
91
+ )
92
+
93
+ def to_model_output(self) -> str:
94
+ if self.success:
95
+ return self.output
96
+
97
+ return f"Error: {self.error}\n\nOutput:\n{self.output}"
98
+
99
+
100
+ @dataclass
101
+ class ToolConfirmation:
102
+ tool_name: str
103
+ params: dict[str, Any]
104
+ description: str
105
+ diff: FileDiff | None = None
106
+ affected_paths: list[Path] = field(default_factory=list)
107
+
108
+ @dataclass
109
+ class ToolInvocation:
110
+ params: dict[str, Any]
111
+ cwd: Path
112
+
113
+ class Tool(abc.ABC):
114
+ name: str = "base_tool"
115
+ description: str = "Base tool"
116
+ kind: ToolKind = ToolKind.READ
117
+
118
+ def __init__(self, config: Config) -> None:
119
+ self.config = config
120
+
121
+ @property
122
+ def schema(self) -> dict[str, Any] | type['BaseModel']:
123
+ raise NotImplementedError("Error: Tool must defined a schema property or an attribute.")
124
+
125
+ @abc.abstractmethod
126
+ async def execute(self, invocation: ToolInvocation) -> ToolResult:
127
+ pass
128
+
129
+ def validate_params(self, params: dict[str, Any]) -> list[str]:
130
+ schema = self.schema
131
+ if isinstance(schema, type) and issubclass(schema, BaseModel):
132
+ try:
133
+ schema(**params)
134
+ except ValidationError as e:
135
+ errors = []
136
+ for error in e.errors():
137
+ loc = ".".join(str(x) for x in error.get("loc", []))
138
+ msg = error.get('msg', 'Validation Error')
139
+ errors.append(f"Parameter '{loc}': {msg}")
140
+
141
+ return errors
142
+ except Exception as e:
143
+ return [str(e)]
144
+
145
+ return []
146
+
147
+ def is_mutating(self, params: dict[str, Any]) -> bool:
148
+ return self.kind in {ToolKind.WRITE, ToolKind.SHELL, ToolKind.NETWORK, ToolKind.MEMORY}
149
+
150
+ async def get_confirmation(self, invocation: ToolInvocation) -> ToolConfirmation | None:
151
+ if not self.is_mutating(invocation.params):
152
+ return None
153
+
154
+ return ToolConfirmation(
155
+ tool_name=self.name,
156
+ params=invocation.params,
157
+ description=f"Executed: {self.name}"
158
+ )
159
+
160
+ def to_openai_schema(self) -> dict[str, Any]:
161
+ schema = self.schema
162
+
163
+ if isinstance(schema, type) and issubclass(schema, BaseModel):
164
+ json_schema = model_json_schema(schema, mode='serialization')
165
+
166
+ return {
167
+ 'name': self.name,
168
+ 'description': self.description,
169
+ 'parameters': {
170
+ 'type': 'object',
171
+ 'properties': json_schema.get('properties', {}),
172
+ 'required': json_schema.get('required', [])
173
+ }
174
+ }
175
+ if isinstance(schema, dict):
176
+ result = {
177
+ 'name': self.name,
178
+ 'description': self.description,
179
+ }
180
+
181
+ if 'parameters' in schema:
182
+ result['parameters'] = schema["parameters"]
183
+ else:
184
+ result["parameters"] = schema # type: ignore
185
+
186
+ return result
187
+
188
+ raise ValueError(f"Error: Invalid schema type for tool: {self.name}: {type(schema)}")
tools/core/__init__.py ADDED
@@ -0,0 +1,44 @@
1
+ from tools.core.directories import ListDirectoriesTool
2
+ from tools.core.read import ReadFileTool
3
+ from tools.core.write import WriteFileTool
4
+ from tools.core.edit import EditTool
5
+ from tools.core.shell import ShellTool
6
+ from tools.core.grep import GrepTool
7
+ from tools.core.glob import GlobTool
8
+ from tools.core.todo import TodoTool
9
+
10
+ from tools.network.search import WebSearchTool
11
+ from tools.network.fetch import WebFetchTool
12
+
13
+ from tools.memory.memory import MemoryTool
14
+
15
+ from tools.base import Tool
16
+
17
+ __all__ = [
18
+ 'ReadFileTool',
19
+ 'WriteFileTool',
20
+ 'EditTool',
21
+ 'ShellTool',
22
+ 'ListDirectoriesTool',
23
+ 'GrepTool',
24
+ 'GlobTool',
25
+ 'WebSearchTool',
26
+ 'WebFetchTool',
27
+ 'TodoTool',
28
+ 'MemoryTool',
29
+ ]
30
+
31
+ def get_all_core_tools() -> list[Tool]:
32
+ return [
33
+ ReadFileTool,
34
+ WriteFileTool,
35
+ EditTool,
36
+ ShellTool,
37
+ ListDirectoriesTool,
38
+ GrepTool,
39
+ GlobTool,
40
+ WebSearchTool,
41
+ WebFetchTool,
42
+ TodoTool,
43
+ MemoryTool,
44
+ ]
@@ -0,0 +1,65 @@
1
+ from tools.base import Tool, ToolInvocation, ToolKind, ToolResult
2
+ from pydantic import BaseModel, Field
3
+
4
+ from utils.paths import resolve_path
5
+
6
+ class ListDirectoriesParams(BaseModel):
7
+ path: str = Field(
8
+ '.',
9
+ description='Directory path to list (default: current directory)'
10
+ )
11
+
12
+ include_hidden: bool = Field(
13
+ False,
14
+ description="Whether to include hidden files and directories (ex: .git, .env) (default: false)"
15
+ )
16
+
17
+ class ListDirectoriesTool(Tool):
18
+ name = 'list_directories'
19
+ description = 'List contents of a directory'
20
+ kind = ToolKind.READ
21
+ schema = ListDirectoriesParams
22
+
23
+ async def execute(self, invocation: ToolInvocation) -> ToolResult:
24
+ params = ListDirectoriesParams(**invocation.params)
25
+
26
+ dir_path = resolve_path(invocation.cwd, params.path)\
27
+
28
+ if not dir_path.exists() or not dir_path.is_dir():
29
+ return ToolResult.error_result(
30
+ f'Directory does not exist: {dir_path}'
31
+ )
32
+ try:
33
+ items = sorted(dir_path.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))
34
+ except Exception as e:
35
+ return ToolResult.error_result(
36
+ f'Error listing directory: {e}'
37
+ )
38
+
39
+ if not params.include_hidden:
40
+ items = [item for item in items if not item.name.startswith(".")]
41
+
42
+ if not items:
43
+ return ToolResult.success_result(
44
+ 'Directory is empty',
45
+ metadata = {
46
+ 'path': str(dir_path),
47
+ 'entries': 0
48
+ }
49
+ )
50
+
51
+ lines = []
52
+
53
+ for item in items:
54
+ if item.is_dir():
55
+ lines.append(f'{item.name}/')
56
+ else:
57
+ lines.append(item.name)
58
+
59
+ return ToolResult.success_result(
60
+ '\n'.join(lines),
61
+ metadata = {
62
+ 'path': str(dir_path),
63
+ 'entries': len(items)
64
+ }
65
+ )
tools/core/edit.py ADDED
@@ -0,0 +1,162 @@
1
+ from pathlib import Path
2
+
3
+ from tools.base import FileDiff, Tool, ToolInvocation, ToolKind, ToolResult
4
+ from pydantic import BaseModel, Field
5
+
6
+ from utils.paths import ensure_parent_dir, resolve_path
7
+
8
+ class EditParams(BaseModel):
9
+ path: str = Field(
10
+ ...,
11
+ description='Path to the file to edit (relative to working directory) or an absolute path.'
12
+ )
13
+ old_string: str = Field(
14
+ "",
15
+ description='The exact text to find and replace and must match excatly including all whitespace and indentation, for new files this should be empty'
16
+ )
17
+ new_string: str = Field(
18
+ ...,
19
+ description='The text to replace old_string with. Can be empty to delete.'
20
+ )
21
+ replace_all: bool = Field(
22
+ False,
23
+ description='Replace all occurrences of old_string default is false'
24
+ )
25
+
26
+ class EditTool(Tool):
27
+ name = "edit"
28
+ description = (
29
+ "Edit a file by replacing text, the old_string varaible must match exactly"
30
+ "( including whitespace and indentation ) and must be unique in the file"
31
+ "unless replace_all is true, use thsi for precise, surgical edits"
32
+ "For creating new files or complete rewrites, use write instead"
33
+ )
34
+ kind = ToolKind.WRITE
35
+ schema = EditParams # type: ignore
36
+
37
+ async def execute(
38
+ self,
39
+ invocation: ToolInvocation
40
+ ) -> ToolResult:
41
+
42
+ params = EditParams(**invocation.params)
43
+ path = resolve_path(invocation.cwd, params.path)
44
+
45
+ if not path.exists():
46
+ if params.old_string:
47
+ return ToolResult.error_result(
48
+ f'File does not exist: {path}. To create a new file, use an empty old_string.'
49
+ )
50
+
51
+ ensure_parent_dir(path)
52
+ path.write_text(params.new_string, encoding='utf-8')
53
+
54
+ line_count = len(params.new_string.splitlines())
55
+
56
+ return ToolResult.success_result(
57
+ f'Created: {path} {line_count} lines',
58
+ diff=FileDiff(
59
+ path=path,
60
+ old_content="",
61
+ new_content=params.new_string,
62
+ is_new_file=True,
63
+ ),
64
+ metadata = {
65
+ 'path': str(path),
66
+ 'is_new_file': True,
67
+ 'lines': line_count
68
+ }
69
+ )
70
+
71
+ old_content = path.read_text(encoding='utf-8')
72
+
73
+ if not params.old_string:
74
+ return ToolResult.error_result(
75
+ 'old_string is empty but file exists. Provide old_string to edit, or use write to overwrite the file.'
76
+ )
77
+
78
+ occurrence_count = old_content.count(params.old_string)
79
+
80
+ if occurrence_count == 0:
81
+ return self._no_match_error(params.old_string, old_content, path)
82
+
83
+ if occurrence_count > 1 and not params.replace_all:
84
+ return ToolResult.error_result(
85
+ f"old_string found {occurrence_count} times in {path}. "
86
+ f"Either: \n"
87
+ f"1. Provide more context to make the match unique or\n"
88
+ f"2. Set replace_all=true to replace all occurrences",
89
+ metadata={
90
+ "occurence_count": occurrence_count,
91
+ },
92
+ )
93
+
94
+ if params.replace_all:
95
+ new_content = old_content.replace(params.old_string, params.new_string)
96
+ replace_count = occurrence_count
97
+ else:
98
+ new_content = old_content.replace(params.old_string, params.new_string, 1)
99
+ replace_count = 1
100
+
101
+ if new_content == old_content:
102
+ return ToolResult.error_result(
103
+ "No change made - old_string equals new_string"
104
+ )
105
+
106
+ try:
107
+ path.write_text(new_content, encoding="utf-8")
108
+ except IOError as e:
109
+ return ToolResult.error_result(f"failed to write file: {e}")
110
+
111
+ old_lines = len(old_content.splitlines())
112
+ new_lines = len(new_content.splitlines())
113
+ line_diff = new_lines - old_lines
114
+
115
+ diff_msg = ""
116
+
117
+ if line_diff > 0:
118
+ diff_msg = f" (+{line_diff} lines)"
119
+ elif line_diff < 0:
120
+ diff_msg = f" ({line_diff} lines)"
121
+
122
+ return ToolResult.success_result(
123
+ f"Edited {path}: replaced {replace_count} occurrence(s){diff_msg}",
124
+ diff=FileDiff(path=path, old_content=old_content, new_content=new_content),
125
+ metadata={
126
+ "path": str(path),
127
+ "replaced_count": replace_count,
128
+ "line_diff": line_diff,
129
+ },
130
+ )
131
+
132
+ def _no_match_error(self, old_string: str, content: str, path: Path) -> ToolResult:
133
+ lines = content.splitlines()
134
+
135
+ partial_matches = []
136
+ search_terms = old_string.split()[:5]
137
+
138
+ if search_terms:
139
+ first_term = search_terms[0]
140
+ for i, line in enumerate(lines, 1):
141
+ if first_term in line:
142
+ partial_matches.append((i, line.strip()[:80]))
143
+ if len(partial_matches) >= 3:
144
+ break
145
+
146
+ error_msg = f"old_string not found in {path}."
147
+
148
+ if partial_matches:
149
+ error_msg += "\n\nPossible similar lines:"
150
+ for line_num, line_preview in partial_matches:
151
+ error_msg += f"\n Line {line_num}: {line_preview}"
152
+ error_msg += "\n\nMake sure old_string matches exactly (including whitespace and indentation)."
153
+ else:
154
+ error_msg += (
155
+ " Make sure the text matches exactly, including:\n"
156
+ "- All whitespace and indentation\n"
157
+ "- Line breaks\n"
158
+ "- Any invisible characters\n"
159
+ "Try re-reading the file using read tool and then editing."
160
+ )
161
+
162
+ return ToolResult.error_result(error_msg)
tools/core/glob.py ADDED
@@ -0,0 +1,92 @@
1
+ import os
2
+ from pathlib import Path
3
+
4
+ from tools.base import Tool, ToolInvocation, ToolKind, ToolResult
5
+ from pydantic import BaseModel, Field
6
+
7
+ from utils.paths import is_binary_file, resolve_path
8
+
9
+ class GlobParams(BaseModel):
10
+
11
+ pattern: str = Field(
12
+ ...,
13
+ description='Glob pattern to match for.'
14
+ )
15
+
16
+ path: str = Field(
17
+ '.',
18
+ description='Directory path to search in (default: current directory)'
19
+ )
20
+
21
+ class GlobTool(Tool):
22
+ name = 'glob'
23
+ description = 'Find files matching a glob pattern. Supports ** for recursive search and matching.'
24
+ kind = ToolKind.READ
25
+ schema = GlobParams # type: ignore
26
+
27
+ async def execute(self, invocation: ToolInvocation) -> ToolResult:
28
+ params = GlobParams(**invocation.params)
29
+
30
+ search_path = resolve_path(invocation.cwd, params.path)\
31
+
32
+ if not search_path.exists() or not search_path.is_dir():
33
+ return ToolResult.error_result(
34
+ f'Directory does not exist: {search_path}'
35
+ )
36
+ try:
37
+ matches = list(search_path.glob(params.pattern))
38
+ matches = [p for p in matches if p.is_file()]
39
+ except Exception as e:
40
+ return ToolResult.error_result(
41
+ f'Error searching: {e}'
42
+ )
43
+
44
+ output_lines: list = []
45
+
46
+ for file_path in matches[:1000]:
47
+
48
+ try:
49
+ relative_path = file_path.relative_to(
50
+ invocation.cwd
51
+ )
52
+ except Exception:
53
+ relative_path = file_path
54
+
55
+ output_lines.append(str(relative_path))
56
+
57
+ if len(matches) > 1000:
58
+ output_lines.append(f"...(limited to 1000 results)")
59
+ return ToolResult.success_result(
60
+ '\n'.join(output_lines),
61
+ metadata = {
62
+ 'path': str(search_path),
63
+ 'matches': len(matches),
64
+ }
65
+ )
66
+
67
+ def _find_files(self, search_path: Path) -> list[Path]:
68
+
69
+ files = []
70
+
71
+ for root, dirs, filenames in os.walk(search_path):
72
+ dirs[:] = [
73
+ d for d in dirs if d not in
74
+ {
75
+ 'node_modules',
76
+ '__pycache__',
77
+ '.git',
78
+ '.venv',
79
+ 'venv',
80
+ }
81
+ ]
82
+ for filename in filenames:
83
+ if filename.startswith('.'):
84
+ continue
85
+
86
+ file_path = Path(root) / filename
87
+ if not is_binary_file(file_path):
88
+ files.append(file_path)
89
+ if len(files) >= 500:
90
+ return files
91
+
92
+ return files
tools/core/grep.py ADDED
@@ -0,0 +1,128 @@
1
+ import os
2
+ from pathlib import Path
3
+ import re
4
+
5
+ from tools.base import Tool, ToolInvocation, ToolKind, ToolResult
6
+ from pydantic import BaseModel, Field
7
+
8
+ from utils.paths import is_binary_file, resolve_path
9
+
10
+ class GrepParams(BaseModel):
11
+
12
+ pattern: str = Field(
13
+ ...,
14
+ description='Regular expression pattern to search for.'
15
+ )
16
+
17
+ path: str = Field(
18
+ '.',
19
+ description='File or Directory path to search in (default: current directory)'
20
+ )
21
+
22
+ case_insensitive: bool = Field(
23
+ False,
24
+ description="Case-insensitive search (default: false)"
25
+ )
26
+
27
+ class GrepTool(Tool):
28
+ name = 'grep'
29
+ description = 'Searching for a regex pattern in the file content and will return the matching lines with file paths and line numbers.'
30
+ kind = ToolKind.READ
31
+ schema = GrepParams
32
+
33
+ async def execute(self, invocation: ToolInvocation) -> ToolResult:
34
+ params = GrepParams(**invocation.params)
35
+
36
+ search_path = resolve_path(invocation.cwd, params.path)\
37
+
38
+ if not search_path.exists() or not search_path.is_dir():
39
+ return ToolResult.error_result(
40
+ f'Path does not exist: {search_path}'
41
+ )
42
+ try:
43
+ flags = re.IGNORECASE if params.case_insensitive else 0
44
+ pattern = re.compile(params.pattern, flags)
45
+ except re.error as e:
46
+ return ToolResult.error_result(
47
+ f'Invalid regex pattern: {e}'
48
+ )
49
+
50
+ if search_path.is_dir():
51
+ files = self._find_files(search_path)
52
+ else:
53
+ files = [search_path]
54
+
55
+ output_lines: list = []
56
+ matches: int = 0
57
+
58
+ for file_path in files:
59
+
60
+ try:
61
+ content = file_path.read_text(
62
+ encoding='utf-8'
63
+ )
64
+ except Exception:
65
+ continue
66
+
67
+ lines = content.splitlines()
68
+ file_matches = False
69
+
70
+ for i, line in enumerate(lines, start=1):
71
+ if pattern.search(line):
72
+ matches += 1
73
+ if not file_matches:
74
+ relative_path = file_path.relative_to(invocation.cwd)
75
+ output_lines.append(f'┈┈ {relative_path} ┈┈')
76
+ file_matches = True
77
+
78
+ output_lines.append(f"{i}:{line}")
79
+
80
+ if file_matches:
81
+ output_lines.append("")
82
+
83
+ if not output_lines:
84
+
85
+ return ToolResult.success_result(
86
+ f'No matches found for pattern: "{params.pattern}"',
87
+ metadata = {
88
+ 'path': str(search_path),
89
+ 'matches': 0,
90
+ 'files_searched': len(files)
91
+ }
92
+ )
93
+
94
+ return ToolResult.success_result(
95
+ '\n'.join(output_lines),
96
+ metadata = {
97
+ 'path': str(search_path),
98
+ 'matches': matches,
99
+ 'files_searched': len(files)
100
+ }
101
+ )
102
+
103
+ def _find_files(self, search_path: Path) -> list[Path]:
104
+
105
+ files = []
106
+
107
+ for root, dirs, filenames in os.walk(search_path):
108
+ dirs[:] = [
109
+ d for d in dirs if d not in
110
+ {
111
+ 'node_modules',
112
+ '__pycache__',
113
+ '.git',
114
+ '.venv',
115
+ 'venv',
116
+ }
117
+ ]
118
+ for filename in filenames:
119
+ if filename.startswith('.'):
120
+ continue
121
+
122
+ file_path = Path(root) / filename
123
+ if not is_binary_file(file_path):
124
+ files.append(file_path)
125
+ if len(files) >= 500:
126
+ return files
127
+
128
+ return files