zrb 1.13.1__py3-none-any.whl → 1.21.17__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.
- zrb/__init__.py +2 -6
- zrb/attr/type.py +8 -8
- zrb/builtin/__init__.py +2 -0
- zrb/builtin/group.py +31 -15
- zrb/builtin/http.py +7 -8
- zrb/builtin/llm/attachment.py +40 -0
- zrb/builtin/llm/chat_session.py +130 -144
- zrb/builtin/llm/chat_session_cmd.py +226 -0
- zrb/builtin/llm/chat_trigger.py +73 -0
- zrb/builtin/llm/history.py +4 -4
- zrb/builtin/llm/llm_ask.py +218 -110
- zrb/builtin/llm/tool/api.py +74 -62
- zrb/builtin/llm/tool/cli.py +35 -16
- zrb/builtin/llm/tool/code.py +49 -47
- zrb/builtin/llm/tool/file.py +262 -251
- zrb/builtin/llm/tool/note.py +84 -0
- zrb/builtin/llm/tool/rag.py +25 -18
- zrb/builtin/llm/tool/sub_agent.py +29 -22
- zrb/builtin/llm/tool/web.py +135 -143
- zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/_zrb/entity/add_entity_util.py +7 -7
- zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/_zrb/module/add_module_util.py +5 -5
- zrb/builtin/project/add/fastapp/fastapp_util.py +1 -1
- zrb/builtin/searxng/config/settings.yml +5671 -0
- zrb/builtin/searxng/start.py +21 -0
- zrb/builtin/setup/latex/ubuntu.py +1 -0
- zrb/builtin/setup/ubuntu.py +1 -1
- zrb/builtin/shell/autocomplete/bash.py +4 -3
- zrb/builtin/shell/autocomplete/zsh.py +4 -3
- zrb/config/config.py +255 -78
- zrb/config/default_prompt/file_extractor_system_prompt.md +109 -9
- zrb/config/default_prompt/interactive_system_prompt.md +24 -30
- zrb/config/default_prompt/persona.md +1 -1
- zrb/config/default_prompt/repo_extractor_system_prompt.md +31 -31
- zrb/config/default_prompt/repo_summarizer_system_prompt.md +27 -8
- zrb/config/default_prompt/summarization_prompt.md +8 -13
- zrb/config/default_prompt/system_prompt.md +36 -30
- zrb/config/llm_config.py +129 -24
- zrb/config/llm_context/config.py +127 -90
- zrb/config/llm_context/config_parser.py +1 -7
- zrb/config/llm_context/workflow.py +81 -0
- zrb/config/llm_rate_limitter.py +89 -45
- zrb/context/any_shared_context.py +7 -1
- zrb/context/context.py +8 -2
- zrb/context/shared_context.py +6 -8
- zrb/group/any_group.py +12 -5
- zrb/group/group.py +67 -3
- zrb/input/any_input.py +5 -1
- zrb/input/base_input.py +18 -6
- zrb/input/text_input.py +7 -24
- zrb/runner/cli.py +21 -20
- zrb/runner/common_util.py +24 -19
- zrb/runner/web_route/task_input_api_route.py +5 -5
- zrb/runner/web_route/task_session_api_route.py +1 -4
- zrb/runner/web_util/user.py +7 -3
- zrb/session/any_session.py +12 -6
- zrb/session/session.py +39 -18
- zrb/task/any_task.py +24 -3
- zrb/task/base/context.py +17 -9
- zrb/task/base/execution.py +15 -8
- zrb/task/base/lifecycle.py +8 -4
- zrb/task/base/monitoring.py +12 -7
- zrb/task/base_task.py +69 -5
- zrb/task/base_trigger.py +12 -5
- zrb/task/llm/agent.py +138 -52
- zrb/task/llm/config.py +45 -13
- zrb/task/llm/conversation_history.py +76 -6
- zrb/task/llm/conversation_history_model.py +0 -168
- zrb/task/llm/default_workflow/coding/workflow.md +41 -0
- zrb/task/llm/default_workflow/copywriting/workflow.md +68 -0
- zrb/task/llm/default_workflow/git/workflow.md +118 -0
- zrb/task/llm/default_workflow/golang/workflow.md +128 -0
- zrb/task/llm/default_workflow/html-css/workflow.md +135 -0
- zrb/task/llm/default_workflow/java/workflow.md +146 -0
- zrb/task/llm/default_workflow/javascript/workflow.md +158 -0
- zrb/task/llm/default_workflow/python/workflow.md +160 -0
- zrb/task/llm/default_workflow/researching/workflow.md +153 -0
- zrb/task/llm/default_workflow/rust/workflow.md +162 -0
- zrb/task/llm/default_workflow/shell/workflow.md +299 -0
- zrb/task/llm/file_replacement.py +206 -0
- zrb/task/llm/file_tool_model.py +57 -0
- zrb/task/llm/history_summarization.py +22 -35
- zrb/task/llm/history_summarization_tool.py +24 -0
- zrb/task/llm/print_node.py +182 -63
- zrb/task/llm/prompt.py +213 -153
- zrb/task/llm/tool_wrapper.py +210 -53
- zrb/task/llm/workflow.py +76 -0
- zrb/task/llm_task.py +98 -47
- zrb/task/make_task.py +2 -3
- zrb/task/rsync_task.py +25 -10
- zrb/task/scheduler.py +4 -4
- zrb/util/attr.py +50 -40
- zrb/util/cli/markdown.py +12 -0
- zrb/util/cli/text.py +30 -0
- zrb/util/file.py +27 -11
- zrb/util/{llm/prompt.py → markdown.py} +2 -3
- zrb/util/string/conversion.py +1 -1
- zrb/util/truncate.py +23 -0
- zrb/util/yaml.py +204 -0
- {zrb-1.13.1.dist-info → zrb-1.21.17.dist-info}/METADATA +40 -20
- {zrb-1.13.1.dist-info → zrb-1.21.17.dist-info}/RECORD +102 -79
- {zrb-1.13.1.dist-info → zrb-1.21.17.dist-info}/WHEEL +1 -1
- zrb/task/llm/default_workflow/coding.md +0 -24
- zrb/task/llm/default_workflow/copywriting.md +0 -17
- zrb/task/llm/default_workflow/researching.md +0 -18
- {zrb-1.13.1.dist-info → zrb-1.21.17.dist-info}/entry_points.txt +0 -0
zrb/builtin/llm/tool/file.py
CHANGED
|
@@ -2,17 +2,15 @@ import fnmatch
|
|
|
2
2
|
import json
|
|
3
3
|
import os
|
|
4
4
|
import re
|
|
5
|
-
from typing import Any,
|
|
5
|
+
from typing import Any, Optional
|
|
6
6
|
|
|
7
7
|
from zrb.builtin.llm.tool.sub_agent import create_sub_agent_tool
|
|
8
8
|
from zrb.config.config import CFG
|
|
9
9
|
from zrb.config.llm_rate_limitter import llm_rate_limitter
|
|
10
10
|
from zrb.context.any_context import AnyContext
|
|
11
|
+
from zrb.task.llm.file_tool_model import FileReplacement, FileToRead, FileToWrite
|
|
11
12
|
from zrb.util.file import read_file, read_file_with_line_numbers, write_file
|
|
12
13
|
|
|
13
|
-
_EXTRACT_INFO_FROM_FILE_SYSTEM_PROMPT = CFG.LLM_FILE_EXTRACTOR_SYSTEM_PROMPT
|
|
14
|
-
|
|
15
|
-
|
|
16
14
|
DEFAULT_EXCLUDED_PATTERNS = [
|
|
17
15
|
# Common Python artifacts
|
|
18
16
|
"__pycache__",
|
|
@@ -84,26 +82,25 @@ DEFAULT_EXCLUDED_PATTERNS = [
|
|
|
84
82
|
|
|
85
83
|
def list_files(
|
|
86
84
|
path: str = ".",
|
|
87
|
-
recursive: bool = True,
|
|
88
85
|
include_hidden: bool = False,
|
|
86
|
+
depth: int = 3,
|
|
89
87
|
excluded_patterns: Optional[list[str]] = None,
|
|
90
|
-
) -> str:
|
|
88
|
+
) -> dict[str, list[str]]:
|
|
91
89
|
"""
|
|
92
|
-
Lists
|
|
90
|
+
Lists files recursively up to a specified depth.
|
|
93
91
|
|
|
94
|
-
|
|
92
|
+
Example:
|
|
93
|
+
list_files(path='src', include_hidden=False, depth=2)
|
|
95
94
|
|
|
96
95
|
Args:
|
|
97
|
-
path (str
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
96
|
+
path (str): Directory path. Defaults to current directory.
|
|
97
|
+
include_hidden (bool): Include hidden files. Defaults to False.
|
|
98
|
+
depth (int): Maximum depth to traverse. Defaults to 3.
|
|
99
|
+
Minimum depth is 1 (current directory only).
|
|
100
|
+
excluded_patterns (list[str]): Glob patterns to exclude.
|
|
101
101
|
|
|
102
102
|
Returns:
|
|
103
|
-
|
|
104
|
-
Example: '{"files": ["src/main.py", "README.md"]}'
|
|
105
|
-
Raises:
|
|
106
|
-
FileNotFoundError: If the specified path does not exist.
|
|
103
|
+
dict: {'files': [relative_paths]}
|
|
107
104
|
"""
|
|
108
105
|
all_files: list[str] = []
|
|
109
106
|
abs_path = os.path.abspath(os.path.expanduser(path))
|
|
@@ -117,50 +114,31 @@ def list_files(
|
|
|
117
114
|
if excluded_patterns is not None
|
|
118
115
|
else DEFAULT_EXCLUDED_PATTERNS
|
|
119
116
|
)
|
|
117
|
+
if depth <= 0:
|
|
118
|
+
depth = 1
|
|
120
119
|
try:
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
# Check rel path for patterns like '**/node_modules/*'
|
|
137
|
-
rel_full_path = os.path.relpath(full_path, abs_path)
|
|
138
|
-
is_rel_path_excluded = is_excluded(
|
|
139
|
-
rel_full_path, patterns_to_exclude
|
|
140
|
-
)
|
|
141
|
-
if not is_rel_path_excluded:
|
|
142
|
-
all_files.append(full_path)
|
|
143
|
-
else:
|
|
144
|
-
# Non-recursive listing (top-level only)
|
|
145
|
-
for item in os.listdir(abs_path):
|
|
146
|
-
full_path = os.path.join(abs_path, item)
|
|
147
|
-
# Include both files and directories if not recursive
|
|
148
|
-
if (include_hidden or not _is_hidden(item)) and not is_excluded(
|
|
149
|
-
item, patterns_to_exclude
|
|
120
|
+
initial_depth = abs_path.rstrip(os.sep).count(os.sep)
|
|
121
|
+
for root, dirs, files in os.walk(abs_path, topdown=True):
|
|
122
|
+
current_depth = root.rstrip(os.sep).count(os.sep) - initial_depth
|
|
123
|
+
if current_depth >= depth - 1:
|
|
124
|
+
del dirs[:]
|
|
125
|
+
dirs[:] = [
|
|
126
|
+
d
|
|
127
|
+
for d in dirs
|
|
128
|
+
if (include_hidden or not _is_hidden(d))
|
|
129
|
+
and not is_excluded(d, patterns_to_exclude)
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
for filename in files:
|
|
133
|
+
if (include_hidden or not _is_hidden(filename)) and not is_excluded(
|
|
134
|
+
filename, patterns_to_exclude
|
|
150
135
|
):
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
ValueError
|
|
158
|
-
) as e: # Handle case where path is '.' and abs_path is CWD root
|
|
159
|
-
if "path is on mount '" in str(e) and "' which is not on mount '" in str(e):
|
|
160
|
-
# If paths are on different mounts, just use absolute paths
|
|
161
|
-
rel_files = all_files
|
|
162
|
-
return json.dumps({"files": sorted(rel_files)})
|
|
163
|
-
raise
|
|
136
|
+
full_path = os.path.join(root, filename)
|
|
137
|
+
rel_full_path = os.path.relpath(full_path, abs_path)
|
|
138
|
+
if not is_excluded(rel_full_path, patterns_to_exclude):
|
|
139
|
+
all_files.append(rel_full_path)
|
|
140
|
+
return {"files": sorted(all_files)}
|
|
141
|
+
|
|
164
142
|
except (OSError, IOError) as e:
|
|
165
143
|
raise OSError(f"Error listing files in {path}: {e}")
|
|
166
144
|
except Exception as e:
|
|
@@ -197,97 +175,142 @@ def is_excluded(name: str, patterns: list[str]) -> bool:
|
|
|
197
175
|
|
|
198
176
|
|
|
199
177
|
def read_from_file(
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
end_line: Optional[int] = None,
|
|
203
|
-
) -> str:
|
|
178
|
+
file: FileToRead | list[FileToRead],
|
|
179
|
+
) -> dict[str, Any]:
|
|
204
180
|
"""
|
|
205
|
-
Reads
|
|
181
|
+
Reads content from one or more files, optionally specifying line ranges.
|
|
182
|
+
|
|
183
|
+
Examples:
|
|
184
|
+
# Read entire content of a single file
|
|
185
|
+
read_from_file(file={'path': 'path/to/file.txt'})
|
|
206
186
|
|
|
207
|
-
|
|
187
|
+
# Read specific lines from a file
|
|
188
|
+
# The content will be returned with line numbers in the format: "LINE_NUMBER | line content"
|
|
189
|
+
read_from_file(file={'path': 'path/to/large_file.log', 'start_line': 100, 'end_line': 150})
|
|
208
190
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
191
|
+
# Read multiple files
|
|
192
|
+
read_from_file(file=[
|
|
193
|
+
{'path': 'path/to/file1.txt'},
|
|
194
|
+
{'path': 'path/to/file2.txt', 'start_line': 1, 'end_line': 5}
|
|
195
|
+
])
|
|
213
196
|
|
|
214
197
|
Args:
|
|
215
|
-
|
|
216
|
-
start_line (int, optional): The 1-based line number to start reading from. If omitted, reading starts from the beginning of the file.
|
|
217
|
-
end_line (int, optional): The 1-based line number to stop reading at (inclusive). If omitted, reads to the end of the file.
|
|
198
|
+
file (FileToRead | list[FileToRead]): A single file configuration or a list of them.
|
|
218
199
|
|
|
219
200
|
Returns:
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
Raises:
|
|
223
|
-
FileNotFoundError: If the specified file does not exist.
|
|
201
|
+
dict: Content and metadata for a single file, or a dict of results for multiple files.
|
|
202
|
+
The `content` field in the returned dictionary will have line numbers in the format: "LINE_NUMBER | line content"
|
|
224
203
|
"""
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
start_idx =
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
204
|
+
is_list = isinstance(file, list)
|
|
205
|
+
files = file if is_list else [file]
|
|
206
|
+
|
|
207
|
+
results = {}
|
|
208
|
+
for file_config in files:
|
|
209
|
+
path = file_config["path"]
|
|
210
|
+
start_line = file_config.get("start_line", None)
|
|
211
|
+
end_line = file_config.get("end_line", None)
|
|
212
|
+
try:
|
|
213
|
+
abs_path = os.path.abspath(os.path.expanduser(path))
|
|
214
|
+
if not os.path.exists(abs_path):
|
|
215
|
+
raise FileNotFoundError(f"File not found: {path}")
|
|
216
|
+
|
|
217
|
+
content = read_file_with_line_numbers(abs_path)
|
|
218
|
+
lines = content.splitlines()
|
|
219
|
+
total_lines = len(lines)
|
|
220
|
+
|
|
221
|
+
start_idx = (start_line - 1) if start_line is not None else 0
|
|
222
|
+
end_idx = end_line if end_line is not None else total_lines
|
|
223
|
+
|
|
224
|
+
if start_idx < 0:
|
|
225
|
+
start_idx = 0
|
|
226
|
+
if end_idx > total_lines:
|
|
227
|
+
end_idx = total_lines
|
|
228
|
+
if start_idx > end_idx:
|
|
229
|
+
start_idx = end_idx
|
|
230
|
+
|
|
231
|
+
selected_lines = lines[start_idx:end_idx]
|
|
232
|
+
content_result = "\n".join(selected_lines)
|
|
233
|
+
|
|
234
|
+
results[path] = {
|
|
248
235
|
"path": path,
|
|
249
236
|
"content": content_result,
|
|
250
|
-
"start_line": start_idx + 1,
|
|
251
|
-
"end_line": end_idx,
|
|
237
|
+
"start_line": start_idx + 1,
|
|
238
|
+
"end_line": end_idx,
|
|
252
239
|
"total_lines": total_lines,
|
|
253
240
|
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
241
|
+
except Exception as e:
|
|
242
|
+
if not is_list:
|
|
243
|
+
if isinstance(e, (OSError, IOError)):
|
|
244
|
+
raise OSError(f"Error reading file {path}: {e}") from e
|
|
245
|
+
raise RuntimeError(f"Unexpected error reading file {path}: {e}") from e
|
|
246
|
+
results[path] = f"Error reading file: {e}"
|
|
247
|
+
|
|
248
|
+
if is_list:
|
|
249
|
+
return results
|
|
250
|
+
|
|
251
|
+
return results[files[0]["path"]]
|
|
259
252
|
|
|
260
253
|
|
|
261
254
|
def write_to_file(
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
) -> str:
|
|
255
|
+
file: FileToWrite | list[FileToWrite],
|
|
256
|
+
) -> str | dict[str, Any]:
|
|
265
257
|
"""
|
|
266
|
-
Writes content to
|
|
267
|
-
|
|
268
|
-
|
|
258
|
+
Writes content to one or more files, with options for overwrite, append, or exclusive
|
|
259
|
+
creation.
|
|
260
|
+
|
|
261
|
+
**CRITICAL - PREVENT JSON ERRORS:**
|
|
262
|
+
1. **ESCAPING:** Do NOT double-escape quotes.
|
|
263
|
+
- CORRECT: "content": "He said \"Hello\""
|
|
264
|
+
- WRONG: "content": "He said \\"Hello\\"" <-- This breaks JSON parsing!
|
|
265
|
+
2. **SIZE LIMIT:** Content MUST NOT exceed 4000 characters.
|
|
266
|
+
- Exceeding this causes truncation and EOF errors.
|
|
267
|
+
- Split larger content into multiple sequential calls (first 'w', then 'a').
|
|
268
|
+
|
|
269
|
+
Examples:
|
|
270
|
+
# Overwrite 'file.txt' with initial content
|
|
271
|
+
write_to_file(file={'path': 'path/to/file.txt', 'content': 'Initial content.'})
|
|
272
|
+
|
|
273
|
+
# Append a second chunk to 'file.txt' (note the newline at the beginning of the content)
|
|
274
|
+
write_to_file(file={'path': 'path/to/file.txt', 'content': '\nSecond chunk.', 'mode': 'a'})
|
|
275
|
+
|
|
276
|
+
# Write to multiple files
|
|
277
|
+
write_to_file(file=[
|
|
278
|
+
{'path': 'path/to/file1.txt', 'content': 'Content for file 1'},
|
|
279
|
+
{'path': 'path/to/file2.txt', 'content': 'Content for file 2', 'mode': 'w'}
|
|
280
|
+
])
|
|
269
281
|
|
|
270
282
|
Args:
|
|
271
|
-
|
|
272
|
-
content (str): The full, complete content to be written to the file. Do not use partial content or omit any lines.
|
|
283
|
+
file (FileToWrite | list[FileToWrite]): A single file configuration or a list of them.
|
|
273
284
|
|
|
274
285
|
Returns:
|
|
275
|
-
|
|
276
|
-
Example: '{"success": true, "path": "new_file.txt"}'
|
|
286
|
+
Success message for single file, or dict with success/errors for multiple files.
|
|
277
287
|
"""
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
288
|
+
# Normalize to list
|
|
289
|
+
files = file if isinstance(file, list) else [file]
|
|
290
|
+
|
|
291
|
+
success = []
|
|
292
|
+
errors = {}
|
|
293
|
+
for file_config in files:
|
|
294
|
+
path = file_config["path"]
|
|
295
|
+
content = file_config["content"]
|
|
296
|
+
mode = file_config.get("mode", "w")
|
|
297
|
+
try:
|
|
298
|
+
abs_path = os.path.abspath(os.path.expanduser(path))
|
|
299
|
+
# The underlying utility creates the directory, so we don't need to do it here.
|
|
300
|
+
write_file(abs_path, content, mode=mode)
|
|
301
|
+
success.append(path)
|
|
302
|
+
except Exception as e:
|
|
303
|
+
errors[path] = f"Error writing file: {e}"
|
|
304
|
+
|
|
305
|
+
# Return appropriate response based on input type
|
|
306
|
+
if isinstance(file, list):
|
|
307
|
+
return {"success": success, "errors": errors}
|
|
308
|
+
else:
|
|
309
|
+
if errors:
|
|
310
|
+
raise RuntimeError(
|
|
311
|
+
f"Error writing file {file['path']}: {errors[file['path']]}"
|
|
312
|
+
)
|
|
313
|
+
return f"Successfully wrote to file: {file['path']} in mode '{file.get('mode', 'w')}'"
|
|
291
314
|
|
|
292
315
|
|
|
293
316
|
def search_files(
|
|
@@ -295,22 +318,21 @@ def search_files(
|
|
|
295
318
|
regex: str,
|
|
296
319
|
file_pattern: Optional[str] = None,
|
|
297
320
|
include_hidden: bool = True,
|
|
298
|
-
) -> str:
|
|
321
|
+
) -> dict[str, Any]:
|
|
299
322
|
"""
|
|
300
|
-
Searches for a
|
|
323
|
+
Searches for a regex pattern in files within a directory.
|
|
301
324
|
|
|
302
|
-
|
|
325
|
+
Example:
|
|
326
|
+
search_files(path='src', regex='class \\w+', file_pattern='*.py', include_hidden=False)
|
|
303
327
|
|
|
304
328
|
Args:
|
|
305
|
-
path (str):
|
|
306
|
-
regex (str):
|
|
307
|
-
file_pattern (str
|
|
308
|
-
include_hidden (bool
|
|
329
|
+
path (str): Directory to search.
|
|
330
|
+
regex (str): Regex pattern.
|
|
331
|
+
file_pattern (str): Glob pattern filter.
|
|
332
|
+
include_hidden (bool): Include hidden files.
|
|
309
333
|
|
|
310
334
|
Returns:
|
|
311
|
-
|
|
312
|
-
Raises:
|
|
313
|
-
ValueError: If the provided `regex` pattern is invalid.
|
|
335
|
+
dict: Summary and list of matches.
|
|
314
336
|
"""
|
|
315
337
|
try:
|
|
316
338
|
pattern = re.compile(regex)
|
|
@@ -357,9 +379,7 @@ def search_files(
|
|
|
357
379
|
f"Found {match_count} matches in {file_match_count} files "
|
|
358
380
|
f"(searched {searched_file_count} files)."
|
|
359
381
|
)
|
|
360
|
-
return
|
|
361
|
-
search_results
|
|
362
|
-
) # No need for pretty printing for LLM consumption
|
|
382
|
+
return search_results
|
|
363
383
|
except (OSError, IOError) as e:
|
|
364
384
|
raise OSError(f"Error searching files in {path}: {e}")
|
|
365
385
|
except Exception as e:
|
|
@@ -367,7 +387,9 @@ def search_files(
|
|
|
367
387
|
|
|
368
388
|
|
|
369
389
|
def _get_file_matches(
|
|
370
|
-
file_path: str,
|
|
390
|
+
file_path: str,
|
|
391
|
+
pattern: re.Pattern,
|
|
392
|
+
context_lines: int = 2,
|
|
371
393
|
) -> list[dict[str, Any]]:
|
|
372
394
|
"""Search for regex matches in a file with context."""
|
|
373
395
|
try:
|
|
@@ -398,69 +420,109 @@ def _get_file_matches(
|
|
|
398
420
|
|
|
399
421
|
|
|
400
422
|
def replace_in_file(
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
new_string: str,
|
|
404
|
-
) -> str:
|
|
423
|
+
file: FileReplacement | list[FileReplacement],
|
|
424
|
+
) -> str | dict[str, Any]:
|
|
405
425
|
"""
|
|
406
|
-
Replaces
|
|
407
|
-
|
|
408
|
-
|
|
426
|
+
Replaces exact text in files.
|
|
427
|
+
|
|
428
|
+
**CRITICAL INSTRUCTIONS:**
|
|
429
|
+
1. **READ FIRST:** Use `read_file` to get exact content. Do not guess.
|
|
430
|
+
2. **EXACT MATCH:** `old_text` must match file content EXACTLY (whitespace, newlines).
|
|
431
|
+
3. **ESCAPING:** Do NOT double-escape quotes in `new_text`. Use `\"`, not `\\"`.
|
|
432
|
+
4. **SIZE LIMIT:** `new_text` MUST NOT exceed 4000 chars to avoid truncation/EOF errors.
|
|
433
|
+
5. **MINIMAL CONTEXT:** Keep `old_text` small (target lines + 2-3 context lines).
|
|
434
|
+
6. **DEFAULT:** Replaces **ALL** occurrences. Set `count=1` for first occurrence only.
|
|
435
|
+
|
|
436
|
+
Examples:
|
|
437
|
+
# Replace ALL occurrences
|
|
438
|
+
replace_in_file(file=[
|
|
439
|
+
{'path': 'file.txt', 'old_text': 'foo', 'new_text': 'bar'},
|
|
440
|
+
{'path': 'file.txt', 'old_text': 'baz', 'new_text': 'qux'}
|
|
441
|
+
])
|
|
442
|
+
|
|
443
|
+
# Replace ONLY the first occurrence
|
|
444
|
+
replace_in_file(
|
|
445
|
+
file={'path': 'file.txt', 'old_text': 'foo', 'new_text': 'bar', 'count': 1}
|
|
446
|
+
)
|
|
409
447
|
|
|
410
|
-
|
|
448
|
+
# Replace code block (include context for safety)
|
|
449
|
+
replace_in_file(
|
|
450
|
+
file={
|
|
451
|
+
'path': 'app.py',
|
|
452
|
+
'old_text': ' def old_fn():\n pass',
|
|
453
|
+
'new_text': ' def new_fn():\n pass'
|
|
454
|
+
}
|
|
455
|
+
)
|
|
411
456
|
|
|
412
457
|
Args:
|
|
413
|
-
|
|
414
|
-
old_string (str): The exact, verbatim string to search for and replace. This should be a unique, multi-line block of text.
|
|
415
|
-
new_string (str): The new string that will replace the `old_string`.
|
|
458
|
+
file: Single replacement config or list of them.
|
|
416
459
|
|
|
417
460
|
Returns:
|
|
418
|
-
|
|
419
|
-
Raises:
|
|
420
|
-
FileNotFoundError: If the specified file does not exist.
|
|
421
|
-
ValueError: If the `old_string` is not found in the file.
|
|
461
|
+
Success message or error dict.
|
|
422
462
|
"""
|
|
423
|
-
|
|
424
|
-
if
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
463
|
+
# Normalize to list
|
|
464
|
+
file_replacements = file if isinstance(file, list) else [file]
|
|
465
|
+
# Group replacements by file path to minimize file I/O
|
|
466
|
+
replacements_by_path: dict[str, list[FileReplacement]] = {}
|
|
467
|
+
for r in file_replacements:
|
|
468
|
+
path = r["path"]
|
|
469
|
+
if path not in replacements_by_path:
|
|
470
|
+
replacements_by_path[path] = []
|
|
471
|
+
replacements_by_path[path].append(r)
|
|
472
|
+
success = []
|
|
473
|
+
errors = {}
|
|
474
|
+
for path, replacements in replacements_by_path.items():
|
|
475
|
+
try:
|
|
476
|
+
abs_path = os.path.abspath(os.path.expanduser(path))
|
|
477
|
+
if not os.path.exists(abs_path):
|
|
478
|
+
raise FileNotFoundError(f"File not found: {path}")
|
|
479
|
+
content = read_file(abs_path)
|
|
480
|
+
original_content = content
|
|
481
|
+
# Apply all replacements for this file
|
|
482
|
+
for replacement in replacements:
|
|
483
|
+
old_text = replacement["old_text"]
|
|
484
|
+
new_text = replacement["new_text"]
|
|
485
|
+
count = replacement.get("count", -1)
|
|
486
|
+
if old_text not in content:
|
|
487
|
+
raise ValueError(f"old_text not found in file: {path}")
|
|
488
|
+
# Replace occurrences
|
|
489
|
+
content = content.replace(old_text, new_text, count)
|
|
490
|
+
# Only write if content actually changed
|
|
491
|
+
if content != original_content:
|
|
492
|
+
write_file(abs_path, content)
|
|
493
|
+
success.append(path)
|
|
494
|
+
else:
|
|
495
|
+
success.append(f"{path} (no changes needed)")
|
|
496
|
+
except Exception as e:
|
|
497
|
+
errors[path] = f"Error applying replacement to {path}: {e}"
|
|
498
|
+
# Return appropriate response based on input type
|
|
499
|
+
if isinstance(file, list):
|
|
500
|
+
return {"success": success, "errors": errors}
|
|
501
|
+
path = file["path"]
|
|
502
|
+
if errors:
|
|
503
|
+
error_message = errors[path]
|
|
504
|
+
raise RuntimeError(f"Error applying replacement to {path}: {error_message}")
|
|
505
|
+
return f"Successfully applied replacement(s) to {path}"
|
|
439
506
|
|
|
440
507
|
|
|
441
508
|
async def analyze_file(
|
|
442
509
|
ctx: AnyContext, path: str, query: str, token_limit: int | None = None
|
|
443
|
-
) -> str:
|
|
510
|
+
) -> dict[str, Any]:
|
|
444
511
|
"""
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
This tool is ideal for complex questions about a single file that go beyond simple reading or searching. It uses a specialized sub-agent to analyze the file's content in relation to a specific query.
|
|
512
|
+
Analyzes a file using a sub-agent for complex questions.
|
|
448
513
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
- Extract the structure of a file (e.g., "List all the function names in this Python file").
|
|
452
|
-
- Perform a detailed code review of a specific file.
|
|
453
|
-
- Answer complex questions like, "How is the 'User' class used in this file?".
|
|
514
|
+
Example:
|
|
515
|
+
analyze_file(path='src/main.py', query='Summarize the main function.')
|
|
454
516
|
|
|
455
517
|
Args:
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
518
|
+
ctx (AnyContext): The execution context.
|
|
519
|
+
path (str): The path to the file to analyze.
|
|
520
|
+
query (str): A specific analysis query with clear guidelines and
|
|
521
|
+
necessary information.
|
|
522
|
+
token_limit (int | None): Max tokens.
|
|
459
523
|
|
|
460
524
|
Returns:
|
|
461
|
-
|
|
462
|
-
Raises:
|
|
463
|
-
FileNotFoundError: If the specified file does not exist.
|
|
525
|
+
Analysis results.
|
|
464
526
|
"""
|
|
465
527
|
if token_limit is None:
|
|
466
528
|
token_limit = CFG.LLM_FILE_ANALYSIS_TOKEN_LIMIT
|
|
@@ -470,71 +532,20 @@ async def analyze_file(
|
|
|
470
532
|
file_content = read_file(abs_path)
|
|
471
533
|
_analyze_file = create_sub_agent_tool(
|
|
472
534
|
tool_name="analyze_file",
|
|
473
|
-
tool_description=
|
|
535
|
+
tool_description=(
|
|
536
|
+
"Analyze file content using LLM sub-agent "
|
|
537
|
+
"for complex questions about code structure, documentation "
|
|
538
|
+
"quality, or file-specific analysis. Use for questions that "
|
|
539
|
+
"require understanding beyond simple text reading."
|
|
540
|
+
),
|
|
474
541
|
system_prompt=CFG.LLM_FILE_EXTRACTOR_SYSTEM_PROMPT,
|
|
475
542
|
tools=[read_from_file, search_files],
|
|
476
543
|
)
|
|
477
544
|
payload = json.dumps(
|
|
478
|
-
{
|
|
545
|
+
{
|
|
546
|
+
"instruction": query,
|
|
547
|
+
"file_path": abs_path,
|
|
548
|
+
"file_content": llm_rate_limitter.clip_prompt(file_content, token_limit),
|
|
549
|
+
}
|
|
479
550
|
)
|
|
480
|
-
|
|
481
|
-
return await _analyze_file(ctx, clipped_payload)
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
def read_many_files(paths: List[str]) -> str:
|
|
485
|
-
"""
|
|
486
|
-
Reads and returns the full content of multiple files at once.
|
|
487
|
-
|
|
488
|
-
This tool is highly efficient for gathering context from several files simultaneously. Use it when you need to understand how different files in a project relate to each other, or when you need to inspect a set of related configuration or source code files.
|
|
489
|
-
|
|
490
|
-
Args:
|
|
491
|
-
paths (List[str]): A list of paths to the files you want to read. It is crucial to provide accurate paths. Use the `list_files` tool first if you are unsure about the exact file locations.
|
|
492
|
-
|
|
493
|
-
Returns:
|
|
494
|
-
str: A JSON object where keys are the file paths and values are their corresponding contents, prefixed with line numbers. If a file cannot be read, its value will be an error message.
|
|
495
|
-
Example: '{"results": {"src/api.py": "1| import ...", "config.yaml": "1| key: value"}}'
|
|
496
|
-
"""
|
|
497
|
-
results = {}
|
|
498
|
-
for path in paths:
|
|
499
|
-
try:
|
|
500
|
-
abs_path = os.path.abspath(os.path.expanduser(path))
|
|
501
|
-
if not os.path.exists(abs_path):
|
|
502
|
-
raise FileNotFoundError(f"File not found: {path}")
|
|
503
|
-
content = read_file_with_line_numbers(abs_path)
|
|
504
|
-
results[path] = content
|
|
505
|
-
except Exception as e:
|
|
506
|
-
results[path] = f"Error reading file: {e}"
|
|
507
|
-
return json.dumps({"results": results})
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
def write_many_files(files: Dict[str, str]) -> str:
|
|
511
|
-
"""
|
|
512
|
-
Writes content to multiple files in a single, atomic operation.
|
|
513
|
-
|
|
514
|
-
This tool is for applying widespread changes to a project, such as creating a set of new files from a template, updating multiple configuration files, or performing a large-scale refactoring.
|
|
515
|
-
|
|
516
|
-
Each file's content is completely replaced. If a file does not exist, it will be created. If it exists, its current content will be entirely overwritten. Therefore, you must provide the full, intended content for each file.
|
|
517
|
-
|
|
518
|
-
Args:
|
|
519
|
-
files (Dict[str, str]): A dictionary where keys are the file paths and values are the complete contents to be written to those files.
|
|
520
|
-
|
|
521
|
-
Returns:
|
|
522
|
-
str: A JSON object summarizing the operation, listing successfully written files and any files that failed, along with corresponding error messages.
|
|
523
|
-
Example: '{"success": ["file1.py", "file2.txt"], "errors": {}}'
|
|
524
|
-
"""
|
|
525
|
-
success = []
|
|
526
|
-
errors = {}
|
|
527
|
-
for path, content in files.items():
|
|
528
|
-
try:
|
|
529
|
-
abs_path = os.path.abspath(os.path.expanduser(path))
|
|
530
|
-
directory = os.path.dirname(abs_path)
|
|
531
|
-
if directory and not os.path.exists(directory):
|
|
532
|
-
os.makedirs(directory, exist_ok=True)
|
|
533
|
-
write_file(abs_path, content)
|
|
534
|
-
success.append(path)
|
|
535
|
-
except Exception as e:
|
|
536
|
-
errors[path] = f"Error writing file: {e}"
|
|
537
|
-
return json.dumps({"success": success, "errors": errors})
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
apply_diff = replace_in_file
|
|
551
|
+
return await _analyze_file(ctx, payload)
|