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.
- agent/__init__.py +6 -0
- agent/agent.py +162 -0
- agent/events.py +133 -0
- agent/session.py +71 -0
- client/__init__.py +23 -0
- client/llm_client.py +236 -0
- client/response.py +83 -0
- config/__init__.py +6 -0
- config/config.py +76 -0
- config/credentials.py +54 -0
- config/loader.py +108 -0
- config/oauth.py +137 -0
- context/__init__.py +5 -0
- context/manager.py +92 -0
- main.py +231 -0
- prompts/__init__.py +0 -0
- prompts/system.py +350 -0
- relay_code-0.1.0.dist-info/METADATA +64 -0
- relay_code-0.1.0.dist-info/RECORD +50 -0
- relay_code-0.1.0.dist-info/WHEEL +5 -0
- relay_code-0.1.0.dist-info/entry_points.txt +2 -0
- relay_code-0.1.0.dist-info/licenses/LICENSE +674 -0
- relay_code-0.1.0.dist-info/top_level.txt +9 -0
- tools/__init__.py +14 -0
- tools/base.py +188 -0
- tools/core/__init__.py +44 -0
- tools/core/directories.py +65 -0
- tools/core/edit.py +162 -0
- tools/core/glob.py +92 -0
- tools/core/grep.py +128 -0
- tools/core/read.py +129 -0
- tools/core/shell.py +162 -0
- tools/core/todo.py +135 -0
- tools/core/write.py +76 -0
- tools/memory/__init__.py +0 -0
- tools/memory/memory.py +191 -0
- tools/network/__init__.py +0 -0
- tools/network/fetch.py +72 -0
- tools/network/search.py +72 -0
- tools/registry.py +96 -0
- ui/__init__.py +5 -0
- ui/app.py +949 -0
- ui/format.py +145 -0
- ui/logo.py +46 -0
- ui/renderer.py +580 -0
- ui/theme.py +60 -0
- utils/__init__.py +17 -0
- utils/errors.py +47 -0
- utils/paths.py +44 -0
- utils/text.py +74 -0
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
|