zrb 1.15.3__py3-none-any.whl → 1.21.29__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.
Potentially problematic release.
This version of zrb might be problematic. Click here for more details.
- zrb/__init__.py +2 -6
- zrb/attr/type.py +10 -7
- zrb/builtin/__init__.py +2 -0
- zrb/builtin/git.py +12 -1
- zrb/builtin/group.py +31 -15
- zrb/builtin/llm/attachment.py +40 -0
- zrb/builtin/llm/chat_completion.py +274 -0
- zrb/builtin/llm/chat_session.py +126 -167
- zrb/builtin/llm/chat_session_cmd.py +288 -0
- zrb/builtin/llm/chat_trigger.py +79 -0
- zrb/builtin/llm/history.py +4 -4
- zrb/builtin/llm/llm_ask.py +217 -135
- zrb/builtin/llm/tool/api.py +74 -70
- zrb/builtin/llm/tool/cli.py +35 -21
- zrb/builtin/llm/tool/code.py +55 -73
- zrb/builtin/llm/tool/file.py +278 -344
- zrb/builtin/llm/tool/note.py +84 -0
- zrb/builtin/llm/tool/rag.py +27 -34
- zrb/builtin/llm/tool/sub_agent.py +54 -41
- zrb/builtin/llm/tool/web.py +74 -98
- 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/shell/autocomplete/bash.py +4 -3
- zrb/builtin/shell/autocomplete/zsh.py +4 -3
- zrb/config/config.py +202 -27
- 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 +57 -16
- zrb/config/default_prompt/system_prompt.md +36 -30
- zrb/config/llm_config.py +119 -23
- 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 +100 -47
- zrb/context/any_shared_context.py +7 -1
- zrb/context/context.py +8 -2
- zrb/context/shared_context.py +3 -7
- zrb/group/any_group.py +3 -3
- zrb/group/group.py +3 -3
- zrb/input/any_input.py +5 -1
- zrb/input/base_input.py +18 -6
- zrb/input/option_input.py +13 -1
- 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_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 +128 -167
- zrb/task/llm/agent_runner.py +152 -0
- zrb/task/llm/config.py +39 -20
- zrb/task/llm/conversation_history.py +110 -29
- zrb/task/llm/conversation_history_model.py +4 -179
- 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_processor.py +206 -0
- zrb/task/llm/history_summarization.py +2 -193
- zrb/task/llm/print_node.py +184 -64
- zrb/task/llm/prompt.py +175 -179
- zrb/task/llm/subagent_conversation_history.py +41 -0
- zrb/task/llm/tool_wrapper.py +226 -85
- zrb/task/llm/workflow.py +76 -0
- zrb/task/llm_task.py +109 -71
- zrb/task/make_task.py +2 -3
- zrb/task/rsync_task.py +25 -10
- zrb/task/scheduler.py +4 -4
- zrb/util/attr.py +54 -39
- zrb/util/cli/markdown.py +12 -0
- zrb/util/cli/text.py +30 -0
- zrb/util/file.py +12 -3
- zrb/util/git.py +2 -2
- 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/xcom/xcom.py +10 -0
- {zrb-1.15.3.dist-info → zrb-1.21.29.dist-info}/METADATA +38 -18
- {zrb-1.15.3.dist-info → zrb-1.21.29.dist-info}/RECORD +105 -79
- {zrb-1.15.3.dist-info → zrb-1.21.29.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.15.3.dist-info → zrb-1.21.29.dist-info}/entry_points.txt +0 -0
zrb/builtin/llm/tool/file.py
CHANGED
|
@@ -2,28 +2,15 @@ import fnmatch
|
|
|
2
2
|
import json
|
|
3
3
|
import os
|
|
4
4
|
import re
|
|
5
|
-
import sys
|
|
6
5
|
from typing import Any, Optional
|
|
7
6
|
|
|
8
7
|
from zrb.builtin.llm.tool.sub_agent import create_sub_agent_tool
|
|
9
8
|
from zrb.config.config import CFG
|
|
10
9
|
from zrb.config.llm_rate_limitter import llm_rate_limitter
|
|
11
10
|
from zrb.context.any_context import AnyContext
|
|
11
|
+
from zrb.task.llm.file_tool_model import FileReplacement, FileToRead, FileToWrite
|
|
12
12
|
from zrb.util.file import read_file, read_file_with_line_numbers, write_file
|
|
13
13
|
|
|
14
|
-
if sys.version_info >= (3, 12):
|
|
15
|
-
from typing import TypedDict
|
|
16
|
-
else:
|
|
17
|
-
from typing_extensions import TypedDict
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
class FileToWrite(TypedDict):
|
|
21
|
-
"""Represents a file to be written, with a 'path' and 'content'."""
|
|
22
|
-
|
|
23
|
-
path: str
|
|
24
|
-
content: str
|
|
25
|
-
|
|
26
|
-
|
|
27
14
|
DEFAULT_EXCLUDED_PATTERNS = [
|
|
28
15
|
# Common Python artifacts
|
|
29
16
|
"__pycache__",
|
|
@@ -95,36 +82,25 @@ DEFAULT_EXCLUDED_PATTERNS = [
|
|
|
95
82
|
|
|
96
83
|
def list_files(
|
|
97
84
|
path: str = ".",
|
|
98
|
-
recursive: bool = True,
|
|
99
85
|
include_hidden: bool = False,
|
|
86
|
+
depth: int = 3,
|
|
100
87
|
excluded_patterns: Optional[list[str]] = None,
|
|
101
|
-
) -> str:
|
|
88
|
+
) -> dict[str, list[str]]:
|
|
102
89
|
"""
|
|
103
|
-
Lists
|
|
90
|
+
Lists files recursively up to a specified depth.
|
|
104
91
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
general overview of the project layout before performing other operations.
|
|
92
|
+
Example:
|
|
93
|
+
list_files(path='src', include_hidden=False, depth=2)
|
|
108
94
|
|
|
109
95
|
Args:
|
|
110
|
-
path (str
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
include_hidden (bool, optional): If True, includes hidden files and
|
|
116
|
-
directories (those starting with a dot). Defaults to False.
|
|
117
|
-
excluded_patterns (list[str], optional): A list of glob patterns to
|
|
118
|
-
exclude from the listing. This is useful for ignoring irrelevant
|
|
119
|
-
files like build artifacts or virtual environments. Defaults to a
|
|
120
|
-
standard list of common exclusion patterns.
|
|
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.
|
|
121
101
|
|
|
122
102
|
Returns:
|
|
123
|
-
|
|
124
|
-
relative to the input path.
|
|
125
|
-
Example: '{"files": ["src/main.py", "README.md"]}'
|
|
126
|
-
Raises:
|
|
127
|
-
FileNotFoundError: If the specified path does not exist.
|
|
103
|
+
dict: {'files': [relative_paths]}
|
|
128
104
|
"""
|
|
129
105
|
all_files: list[str] = []
|
|
130
106
|
abs_path = os.path.abspath(os.path.expanduser(path))
|
|
@@ -138,50 +114,30 @@ def list_files(
|
|
|
138
114
|
if excluded_patterns is not None
|
|
139
115
|
else DEFAULT_EXCLUDED_PATTERNS
|
|
140
116
|
)
|
|
117
|
+
if depth <= 0:
|
|
118
|
+
depth = 1
|
|
141
119
|
try:
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
full_path = os.path.join(root, filename)
|
|
157
|
-
# Check rel path for patterns like '**/node_modules/*'
|
|
158
|
-
rel_full_path = os.path.relpath(full_path, abs_path)
|
|
159
|
-
is_rel_path_excluded = is_excluded(
|
|
160
|
-
rel_full_path, patterns_to_exclude
|
|
161
|
-
)
|
|
162
|
-
if not is_rel_path_excluded:
|
|
163
|
-
all_files.append(full_path)
|
|
164
|
-
else:
|
|
165
|
-
# Non-recursive listing (top-level only)
|
|
166
|
-
for item in os.listdir(abs_path):
|
|
167
|
-
full_path = os.path.join(abs_path, item)
|
|
168
|
-
# Include both files and directories if not recursive
|
|
169
|
-
if (include_hidden or not _is_hidden(item)) and not is_excluded(
|
|
170
|
-
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
|
+
for filename in files:
|
|
132
|
+
if (include_hidden or not _is_hidden(filename)) and not is_excluded(
|
|
133
|
+
filename, patterns_to_exclude
|
|
171
134
|
):
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
ValueError
|
|
179
|
-
) as e: # Handle case where path is '.' and abs_path is CWD root
|
|
180
|
-
if "path is on mount '" in str(e) and "' which is not on mount '" in str(e):
|
|
181
|
-
# If paths are on different mounts, just use absolute paths
|
|
182
|
-
rel_files = all_files
|
|
183
|
-
return json.dumps({"files": sorted(rel_files)})
|
|
184
|
-
raise
|
|
135
|
+
full_path = os.path.join(root, filename)
|
|
136
|
+
rel_full_path = os.path.relpath(full_path, abs_path)
|
|
137
|
+
if not is_excluded(rel_full_path, patterns_to_exclude):
|
|
138
|
+
all_files.append(rel_full_path)
|
|
139
|
+
return {"files": sorted(all_files)}
|
|
140
|
+
|
|
185
141
|
except (OSError, IOError) as e:
|
|
186
142
|
raise OSError(f"Error listing files in {path}: {e}")
|
|
187
143
|
except Exception as e:
|
|
@@ -218,113 +174,146 @@ def is_excluded(name: str, patterns: list[str]) -> bool:
|
|
|
218
174
|
|
|
219
175
|
|
|
220
176
|
def read_from_file(
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
end_line: Optional[int] = None,
|
|
224
|
-
) -> str:
|
|
177
|
+
file: FileToRead | list[FileToRead],
|
|
178
|
+
) -> dict[str, Any]:
|
|
225
179
|
"""
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
Example: '{"path": "src/main.py", "content": "1| import os\n2|
|
|
251
|
-
3| print(\"Hello, World!\")", "start_line": 1, "end_line": 3,
|
|
252
|
-
"total_lines": 3}'
|
|
253
|
-
Raises:
|
|
254
|
-
FileNotFoundError: If the specified file does not exist.
|
|
180
|
+
Reads content from one or more files, optionally specifying line ranges.
|
|
181
|
+
|
|
182
|
+
Examples:
|
|
183
|
+
```
|
|
184
|
+
# Read entire content of a single file
|
|
185
|
+
read_from_file(file={'path': 'path/to/file.txt'})
|
|
186
|
+
|
|
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})
|
|
190
|
+
|
|
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
|
+
])
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
file (FileToRead | list[FileToRead]): A single file configuration or a list of them.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
dict: Content and metadata for a single file, or a dict of results for multiple files.
|
|
203
|
+
The `content` field in the returned dictionary will have line numbers in the format: "LINE_NUMBER | line content"
|
|
255
204
|
"""
|
|
205
|
+
is_list = isinstance(file, list)
|
|
206
|
+
files = file if is_list else [file]
|
|
256
207
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
end_idx = total_lines
|
|
273
|
-
|
|
274
|
-
start_idx
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
208
|
+
results = {}
|
|
209
|
+
for file_config in files:
|
|
210
|
+
path = file_config["path"]
|
|
211
|
+
start_line = file_config.get("start_line", None)
|
|
212
|
+
end_line = file_config.get("end_line", None)
|
|
213
|
+
try:
|
|
214
|
+
abs_path = os.path.abspath(os.path.expanduser(path))
|
|
215
|
+
if not os.path.exists(abs_path):
|
|
216
|
+
raise FileNotFoundError(f"File not found: {path}")
|
|
217
|
+
|
|
218
|
+
content = read_file_with_line_numbers(abs_path)
|
|
219
|
+
lines = content.splitlines()
|
|
220
|
+
total_lines = len(lines)
|
|
221
|
+
|
|
222
|
+
start_idx = (start_line - 1) if start_line is not None else 0
|
|
223
|
+
end_idx = end_line if end_line is not None else total_lines
|
|
224
|
+
|
|
225
|
+
if start_idx < 0:
|
|
226
|
+
start_idx = 0
|
|
227
|
+
if end_idx > total_lines:
|
|
228
|
+
end_idx = total_lines
|
|
229
|
+
if start_idx > end_idx:
|
|
230
|
+
start_idx = end_idx
|
|
231
|
+
|
|
232
|
+
selected_lines = lines[start_idx:end_idx]
|
|
233
|
+
content_result = "\n".join(selected_lines)
|
|
234
|
+
|
|
235
|
+
results[path] = {
|
|
280
236
|
"path": path,
|
|
281
237
|
"content": content_result,
|
|
282
|
-
"start_line": start_idx + 1,
|
|
283
|
-
"end_line": end_idx,
|
|
238
|
+
"start_line": start_idx + 1,
|
|
239
|
+
"end_line": end_idx,
|
|
284
240
|
"total_lines": total_lines,
|
|
285
241
|
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
242
|
+
except Exception as e:
|
|
243
|
+
if not is_list:
|
|
244
|
+
if isinstance(e, (OSError, IOError)):
|
|
245
|
+
raise OSError(f"Error reading file {path}: {e}") from e
|
|
246
|
+
raise RuntimeError(f"Unexpected error reading file {path}: {e}") from e
|
|
247
|
+
results[path] = f"Error reading file: {e}"
|
|
248
|
+
|
|
249
|
+
if is_list:
|
|
250
|
+
return results
|
|
251
|
+
|
|
252
|
+
return results[files[0]["path"]]
|
|
291
253
|
|
|
292
254
|
|
|
293
255
|
def write_to_file(
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
) -> str:
|
|
256
|
+
file: FileToWrite | list[FileToWrite],
|
|
257
|
+
) -> str | dict[str, Any]:
|
|
297
258
|
"""
|
|
298
|
-
Writes content to
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
259
|
+
Writes content to one or more files, with options for overwrite, append, or exclusive
|
|
260
|
+
creation.
|
|
261
|
+
|
|
262
|
+
**CRITICAL - PREVENT JSON ERRORS:**
|
|
263
|
+
1. **ESCAPING:** Do NOT double-escape quotes.
|
|
264
|
+
- CORRECT: "content": "He said \"Hello\""
|
|
265
|
+
- WRONG: "content": "He said \\"Hello\\"" <-- This breaks JSON parsing!
|
|
266
|
+
2. **SIZE LIMIT:** Content MUST NOT exceed 4000 characters.
|
|
267
|
+
- Exceeding this causes truncation and EOF errors.
|
|
268
|
+
- Split larger content into multiple sequential calls (first 'w', then 'a').
|
|
269
|
+
|
|
270
|
+
Examples:
|
|
271
|
+
```
|
|
272
|
+
# Overwrite 'file.txt' with initial content
|
|
273
|
+
write_to_file(file={'path': 'path/to/file.txt', 'content': 'Initial content.'})
|
|
274
|
+
|
|
275
|
+
# Append a second chunk to 'file.txt' (note the newline at the beginning of the content)
|
|
276
|
+
write_to_file(file={'path': 'path/to/file.txt', 'content': '\nSecond chunk.', 'mode': 'a'})
|
|
277
|
+
|
|
278
|
+
# Write to multiple files
|
|
279
|
+
write_to_file(file=[
|
|
280
|
+
{'path': 'path/to/file1.txt', 'content': 'Content for file 1'},
|
|
281
|
+
{'path': 'path/to/file2.txt', 'content': 'Content for file 2', 'mode': 'w'}
|
|
282
|
+
])
|
|
283
|
+
```
|
|
305
284
|
|
|
306
285
|
Args:
|
|
307
|
-
|
|
308
|
-
content (str): The full, complete content to be written to the file.
|
|
309
|
-
Do not use partial content or omit any lines.
|
|
286
|
+
file (FileToWrite | list[FileToWrite]): A single file configuration or a list of them.
|
|
310
287
|
|
|
311
288
|
Returns:
|
|
312
|
-
|
|
313
|
-
Example: '{"success": true, "path": "new_file.txt"}'
|
|
289
|
+
Success message for single file, or dict with success/errors for multiple files.
|
|
314
290
|
"""
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
291
|
+
# Normalize to list
|
|
292
|
+
files = file if isinstance(file, list) else [file]
|
|
293
|
+
|
|
294
|
+
success = []
|
|
295
|
+
errors = {}
|
|
296
|
+
for file_config in files:
|
|
297
|
+
path = file_config["path"]
|
|
298
|
+
content = file_config["content"]
|
|
299
|
+
mode = file_config.get("mode", "w")
|
|
300
|
+
try:
|
|
301
|
+
abs_path = os.path.abspath(os.path.expanduser(path))
|
|
302
|
+
# The underlying utility creates the directory, so we don't need to do it here.
|
|
303
|
+
write_file(abs_path, content, mode=mode)
|
|
304
|
+
success.append(path)
|
|
305
|
+
except Exception as e:
|
|
306
|
+
errors[path] = f"Error writing file: {e}"
|
|
307
|
+
|
|
308
|
+
# Return appropriate response based on input type
|
|
309
|
+
if isinstance(file, list):
|
|
310
|
+
return {"success": success, "errors": errors}
|
|
311
|
+
else:
|
|
312
|
+
if errors:
|
|
313
|
+
raise RuntimeError(
|
|
314
|
+
f"Error writing file {file['path']}: {errors[file['path']]}"
|
|
315
|
+
)
|
|
316
|
+
return f"Successfully wrote to file: {file['path']} in mode '{file.get('mode', 'w')}'"
|
|
328
317
|
|
|
329
318
|
|
|
330
319
|
def search_files(
|
|
@@ -332,32 +321,21 @@ def search_files(
|
|
|
332
321
|
regex: str,
|
|
333
322
|
file_pattern: Optional[str] = None,
|
|
334
323
|
include_hidden: bool = True,
|
|
335
|
-
) -> str:
|
|
324
|
+
) -> dict[str, Any]:
|
|
336
325
|
"""
|
|
337
|
-
Searches for a
|
|
338
|
-
specified directory.
|
|
326
|
+
Searches for a regex pattern in files within a directory.
|
|
339
327
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
assignments, error messages, or any other text pattern.
|
|
328
|
+
Example:
|
|
329
|
+
search_files(path='src', regex='class \\w+', file_pattern='*.py', include_hidden=False)
|
|
343
330
|
|
|
344
331
|
Args:
|
|
345
|
-
path (str):
|
|
346
|
-
regex (str):
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
searched (e.g., "*.py", "*.md"). If omitted, all files are
|
|
350
|
-
searched.
|
|
351
|
-
include_hidden (bool, optional): If True, the search will include
|
|
352
|
-
hidden files and directories. Defaults to True.
|
|
332
|
+
path (str): Directory to search.
|
|
333
|
+
regex (str): Regex pattern.
|
|
334
|
+
file_pattern (str): Glob pattern filter.
|
|
335
|
+
include_hidden (bool): Include hidden files.
|
|
353
336
|
|
|
354
337
|
Returns:
|
|
355
|
-
|
|
356
|
-
results. Each result includes the file path and a list of matches,
|
|
357
|
-
with each match showing the line number, line content, and a few
|
|
358
|
-
lines of context from before and after the match.
|
|
359
|
-
Raises:
|
|
360
|
-
ValueError: If the provided `regex` pattern is invalid.
|
|
338
|
+
dict: Summary and list of matches.
|
|
361
339
|
"""
|
|
362
340
|
try:
|
|
363
341
|
pattern = re.compile(regex)
|
|
@@ -404,9 +382,7 @@ def search_files(
|
|
|
404
382
|
f"Found {match_count} matches in {file_match_count} files "
|
|
405
383
|
f"(searched {searched_file_count} files)."
|
|
406
384
|
)
|
|
407
|
-
return
|
|
408
|
-
search_results
|
|
409
|
-
) # No need for pretty printing for LLM consumption
|
|
385
|
+
return search_results
|
|
410
386
|
except (OSError, IOError) as e:
|
|
411
387
|
raise OSError(f"Error searching files in {path}: {e}")
|
|
412
388
|
except Exception as e:
|
|
@@ -414,7 +390,9 @@ def search_files(
|
|
|
414
390
|
|
|
415
391
|
|
|
416
392
|
def _get_file_matches(
|
|
417
|
-
file_path: str,
|
|
393
|
+
file_path: str,
|
|
394
|
+
pattern: re.Pattern,
|
|
395
|
+
context_lines: int = 2,
|
|
418
396
|
) -> list[dict[str, Any]]:
|
|
419
397
|
"""Search for regex matches in a file with context."""
|
|
420
398
|
try:
|
|
@@ -445,182 +423,138 @@ def _get_file_matches(
|
|
|
445
423
|
|
|
446
424
|
|
|
447
425
|
def replace_in_file(
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
new_string: str,
|
|
451
|
-
) -> str:
|
|
426
|
+
file: FileReplacement | list[FileReplacement],
|
|
427
|
+
) -> str | dict[str, Any]:
|
|
452
428
|
"""
|
|
453
|
-
Replaces
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
`
|
|
429
|
+
Replaces exact text in files.
|
|
430
|
+
|
|
431
|
+
**CRITICAL INSTRUCTIONS:**
|
|
432
|
+
1. **READ FIRST:** Use `read_file` to get exact content. Do not guess.
|
|
433
|
+
2. **EXACT MATCH:** `old_text` must match file content EXACTLY (whitespace, newlines).
|
|
434
|
+
3. **ESCAPING:** Do NOT double-escape quotes in `new_text`. Use `\"`, not `\\"`.
|
|
435
|
+
4. **SIZE LIMIT:** `new_text` MUST NOT exceed 4000 chars to avoid truncation/EOF errors.
|
|
436
|
+
5. **MINIMAL CONTEXT:** Keep `old_text` small (target lines + 2-3 context lines).
|
|
437
|
+
6. **DEFAULT:** Replaces **ALL** occurrences. Set `count=1` for first occurrence only.
|
|
438
|
+
|
|
439
|
+
Examples:
|
|
440
|
+
```
|
|
441
|
+
# Replace ALL occurrences
|
|
442
|
+
replace_in_file(file=[
|
|
443
|
+
{'path': 'file.txt', 'old_text': 'foo', 'new_text': 'bar'},
|
|
444
|
+
{'path': 'file.txt', 'old_text': 'baz', 'new_text': 'qux'}
|
|
445
|
+
])
|
|
446
|
+
|
|
447
|
+
# Replace ONLY the first occurrence
|
|
448
|
+
replace_in_file(
|
|
449
|
+
file={'path': 'file.txt', 'old_text': 'foo', 'new_text': 'bar', 'count': 1}
|
|
450
|
+
)
|
|
458
451
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
452
|
+
# Replace code block (include context for safety)
|
|
453
|
+
replace_in_file(
|
|
454
|
+
file={
|
|
455
|
+
'path': 'app.py',
|
|
456
|
+
'old_text': ' def old_fn():\n pass',
|
|
457
|
+
'new_text': ' def new_fn():\n pass'
|
|
458
|
+
}
|
|
459
|
+
)
|
|
460
|
+
```
|
|
462
461
|
|
|
463
462
|
Args:
|
|
464
|
-
|
|
465
|
-
old_string (str): The exact, verbatim string to search for and replace.
|
|
466
|
-
This should be a unique, multi-line block of text.
|
|
467
|
-
new_string (str): The new string that will replace the `old_string`.
|
|
463
|
+
file: Single replacement config or list of them.
|
|
468
464
|
|
|
469
465
|
Returns:
|
|
470
|
-
|
|
471
|
-
Raises:
|
|
472
|
-
FileNotFoundError: If the specified file does not exist.
|
|
473
|
-
ValueError: If the `old_string` is not found in the file.
|
|
466
|
+
Success message or error dict.
|
|
474
467
|
"""
|
|
475
|
-
|
|
476
|
-
if
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
468
|
+
# Normalize to list
|
|
469
|
+
file_replacements = file if isinstance(file, list) else [file]
|
|
470
|
+
# Group replacements by file path to minimize file I/O
|
|
471
|
+
replacements_by_path: dict[str, list[FileReplacement]] = {}
|
|
472
|
+
for r in file_replacements:
|
|
473
|
+
path = r["path"]
|
|
474
|
+
if path not in replacements_by_path:
|
|
475
|
+
replacements_by_path[path] = []
|
|
476
|
+
replacements_by_path[path].append(r)
|
|
477
|
+
success = []
|
|
478
|
+
errors = {}
|
|
479
|
+
for path, replacements in replacements_by_path.items():
|
|
480
|
+
try:
|
|
481
|
+
abs_path = os.path.abspath(os.path.expanduser(path))
|
|
482
|
+
if not os.path.exists(abs_path):
|
|
483
|
+
raise FileNotFoundError(f"File not found: {path}")
|
|
484
|
+
content = read_file(abs_path)
|
|
485
|
+
original_content = content
|
|
486
|
+
# Apply all replacements for this file
|
|
487
|
+
for replacement in replacements:
|
|
488
|
+
old_text = replacement["old_text"]
|
|
489
|
+
new_text = replacement["new_text"]
|
|
490
|
+
count = replacement.get("count", -1)
|
|
491
|
+
if old_text not in content:
|
|
492
|
+
raise ValueError(f"old_text not found in file: {path}")
|
|
493
|
+
# Replace occurrences
|
|
494
|
+
content = content.replace(old_text, new_text, count)
|
|
495
|
+
# Only write if content actually changed
|
|
496
|
+
if content != original_content:
|
|
497
|
+
write_file(abs_path, content)
|
|
498
|
+
success.append(path)
|
|
499
|
+
else:
|
|
500
|
+
success.append(f"{path} (no changes needed)")
|
|
501
|
+
except Exception as e:
|
|
502
|
+
errors[path] = f"Error applying replacement to {path}: {e}"
|
|
503
|
+
# Return appropriate response based on input type
|
|
504
|
+
if isinstance(file, list):
|
|
505
|
+
return {"success": success, "errors": errors}
|
|
506
|
+
path = file["path"]
|
|
507
|
+
if errors:
|
|
508
|
+
error_message = errors[path]
|
|
509
|
+
raise RuntimeError(f"Error applying replacement to {path}: {error_message}")
|
|
510
|
+
return f"Successfully applied replacement(s) to {path}"
|
|
491
511
|
|
|
492
512
|
|
|
493
513
|
async def analyze_file(
|
|
494
|
-
ctx: AnyContext, path: str, query: str,
|
|
495
|
-
) -> str:
|
|
514
|
+
ctx: AnyContext, path: str, query: str, token_threshold: int | None = None
|
|
515
|
+
) -> dict[str, Any]:
|
|
496
516
|
"""
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
This tool is ideal for complex questions about a single file that go beyond
|
|
500
|
-
simple reading or searching. It uses a specialized sub-agent to analyze the
|
|
501
|
-
file's content in relation to a specific query.
|
|
517
|
+
Analyzes a file using a sub-agent for complex questions.
|
|
502
518
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
and may cause the tool to run for a long time.
|
|
506
|
-
|
|
507
|
-
Use this tool to:
|
|
508
|
-
- Summarize the purpose and functionality of a script or configuration file.
|
|
509
|
-
- Extract the structure of a file (e.g., "List all the function names in
|
|
510
|
-
this Python file").
|
|
511
|
-
- Perform a detailed code review of a specific file.
|
|
512
|
-
- Answer complex questions like, "How is the 'User' class used in this
|
|
513
|
-
file?".
|
|
519
|
+
Example:
|
|
520
|
+
analyze_file(path='src/main.py', query='Summarize the main function.')
|
|
514
521
|
|
|
515
522
|
Args:
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
- Good query: "List all the function names in this Python file."
|
|
522
|
-
- Bad query: "Analyze this file."
|
|
523
|
-
- Bad query: "Tell me about this code."
|
|
524
|
-
token_limit (int, optional): The maximum token length of the file
|
|
525
|
-
content to be passed to the analysis sub-agent.
|
|
523
|
+
ctx (AnyContext): The execution context.
|
|
524
|
+
path (str): The path to the file to analyze.
|
|
525
|
+
query (str): A specific analysis query with clear guidelines and
|
|
526
|
+
necessary information.
|
|
527
|
+
token_threshold (int | None): Max tokens.
|
|
526
528
|
|
|
527
529
|
Returns:
|
|
528
|
-
|
|
529
|
-
the specified query.
|
|
530
|
-
Raises:
|
|
531
|
-
FileNotFoundError: If the specified file does not exist.
|
|
530
|
+
Analysis results.
|
|
532
531
|
"""
|
|
533
|
-
if
|
|
534
|
-
|
|
532
|
+
if token_threshold is None:
|
|
533
|
+
token_threshold = CFG.LLM_FILE_ANALYSIS_TOKEN_THRESHOLD
|
|
535
534
|
abs_path = os.path.abspath(os.path.expanduser(path))
|
|
536
535
|
if not os.path.exists(abs_path):
|
|
537
536
|
raise FileNotFoundError(f"File not found: {path}")
|
|
538
537
|
file_content = read_file(abs_path)
|
|
539
538
|
_analyze_file = create_sub_agent_tool(
|
|
540
539
|
tool_name="analyze_file",
|
|
541
|
-
tool_description=
|
|
540
|
+
tool_description=(
|
|
541
|
+
"Analyze file content using LLM sub-agent "
|
|
542
|
+
"for complex questions about code structure, documentation "
|
|
543
|
+
"quality, or file-specific analysis. Use for questions that "
|
|
544
|
+
"require understanding beyond simple text reading."
|
|
545
|
+
),
|
|
542
546
|
system_prompt=CFG.LLM_FILE_EXTRACTOR_SYSTEM_PROMPT,
|
|
543
547
|
tools=[read_from_file, search_files],
|
|
548
|
+
auto_summarize=False,
|
|
549
|
+
remember_history=False,
|
|
544
550
|
)
|
|
545
551
|
payload = json.dumps(
|
|
546
|
-
{
|
|
552
|
+
{
|
|
553
|
+
"instruction": query,
|
|
554
|
+
"file_path": abs_path,
|
|
555
|
+
"file_content": llm_rate_limitter.clip_prompt(
|
|
556
|
+
file_content, token_threshold
|
|
557
|
+
),
|
|
558
|
+
}
|
|
547
559
|
)
|
|
548
|
-
|
|
549
|
-
return await _analyze_file(ctx, clipped_payload)
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
def read_many_files(paths: list[str]) -> str:
|
|
553
|
-
"""
|
|
554
|
-
Reads and returns the full content of multiple files at once.
|
|
555
|
-
|
|
556
|
-
This tool is highly efficient for gathering context from several files
|
|
557
|
-
simultaneously. Use it when you need to understand how different files in a
|
|
558
|
-
project relate to each other, or when you need to inspect a set of related
|
|
559
|
-
configuration or source code files.
|
|
560
|
-
|
|
561
|
-
Args:
|
|
562
|
-
paths (list[str]): A list of paths to the files you want to read. It is
|
|
563
|
-
crucial to provide accurate paths. Use the `list_files` tool first
|
|
564
|
-
if you are unsure about the exact file locations.
|
|
565
|
-
|
|
566
|
-
Returns:
|
|
567
|
-
str: A JSON object where keys are the file paths and values are their
|
|
568
|
-
corresponding contents, prefixed with line numbers. If a file
|
|
569
|
-
cannot be read, its value will be an error message.
|
|
570
|
-
Example: '{"results": {"src/api.py": "1| import ...",
|
|
571
|
-
"config.yaml": "1| key: value"}}'
|
|
572
|
-
"""
|
|
573
|
-
results = {}
|
|
574
|
-
for path in paths:
|
|
575
|
-
try:
|
|
576
|
-
abs_path = os.path.abspath(os.path.expanduser(path))
|
|
577
|
-
if not os.path.exists(abs_path):
|
|
578
|
-
raise FileNotFoundError(f"File not found: {path}")
|
|
579
|
-
content = read_file_with_line_numbers(abs_path)
|
|
580
|
-
results[path] = content
|
|
581
|
-
except Exception as e:
|
|
582
|
-
results[path] = f"Error reading file: {e}"
|
|
583
|
-
return json.dumps({"results": results})
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
def write_many_files(files: list[FileToWrite]) -> str:
|
|
587
|
-
"""
|
|
588
|
-
Writes content to multiple files in a single, atomic operation.
|
|
589
|
-
|
|
590
|
-
This tool is for applying widespread changes to a project, such as
|
|
591
|
-
creating a set of new files from a template, updating multiple
|
|
592
|
-
configuration files, or performing a large-scale refactoring.
|
|
593
|
-
|
|
594
|
-
Each file's content is completely replaced. If a file does not exist, it
|
|
595
|
-
will be created. If it exists, its current content will be entirely
|
|
596
|
-
overwritten. Therefore, you must provide the full, intended content for
|
|
597
|
-
each file.
|
|
598
|
-
|
|
599
|
-
Args:
|
|
600
|
-
files: A list of file objects, where each object is a dictionary
|
|
601
|
-
containing a 'path' and the complete 'content'.
|
|
602
|
-
|
|
603
|
-
Returns:
|
|
604
|
-
str: A JSON object summarizing the operation, listing successfully
|
|
605
|
-
written files and any files that failed, along with corresponding
|
|
606
|
-
error messages.
|
|
607
|
-
Example: '{"success": ["file1.py", "file2.txt"], "errors": {}}'
|
|
608
|
-
"""
|
|
609
|
-
success = []
|
|
610
|
-
errors = {}
|
|
611
|
-
# 4. Access the data using dictionary key-lookup syntax.
|
|
612
|
-
for file in files:
|
|
613
|
-
try:
|
|
614
|
-
# Use file['path'] and file['content'] instead of file.path
|
|
615
|
-
path = file["path"]
|
|
616
|
-
content = file["content"]
|
|
617
|
-
|
|
618
|
-
abs_path = os.path.abspath(os.path.expanduser(path))
|
|
619
|
-
directory = os.path.dirname(abs_path)
|
|
620
|
-
if directory and not os.path.exists(directory):
|
|
621
|
-
os.makedirs(directory, exist_ok=True)
|
|
622
|
-
write_file(abs_path, content)
|
|
623
|
-
success.append(path)
|
|
624
|
-
except Exception as e:
|
|
625
|
-
errors[path] = f"Error writing file: {e}"
|
|
626
|
-
return json.dumps({"success": success, "errors": errors})
|
|
560
|
+
return await _analyze_file(ctx, payload)
|