zrb 1.13.1__py3-none-any.whl → 1.21.33__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 +10 -7
- zrb/builtin/__init__.py +2 -0
- zrb/builtin/git.py +12 -1
- zrb/builtin/group.py +31 -15
- zrb/builtin/http.py +7 -8
- zrb/builtin/llm/attachment.py +40 -0
- zrb/builtin/llm/chat_completion.py +287 -0
- zrb/builtin/llm/chat_session.py +130 -144
- zrb/builtin/llm/chat_session_cmd.py +288 -0
- zrb/builtin/llm/chat_trigger.py +78 -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 +56 -21
- zrb/builtin/llm/tool/code.py +57 -47
- zrb/builtin/llm/tool/file.py +292 -255
- zrb/builtin/llm/tool/note.py +84 -0
- zrb/builtin/llm/tool/rag.py +25 -18
- zrb/builtin/llm/tool/search/__init__.py +1 -0
- zrb/builtin/llm/tool/search/brave.py +66 -0
- zrb/builtin/llm/tool/search/searxng.py +61 -0
- zrb/builtin/llm/tool/search/serpapi.py +61 -0
- zrb/builtin/llm/tool/sub_agent.py +53 -26
- zrb/builtin/llm/tool/web.py +94 -157
- 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 +297 -79
- zrb/config/default_prompt/file_extractor_system_prompt.md +109 -9
- zrb/config/default_prompt/interactive_system_prompt.md +25 -28
- 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 +29 -25
- 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 +100 -47
- 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/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_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 +130 -145
- zrb/task/llm/agent_runner.py +152 -0
- zrb/task/llm/config.py +45 -13
- 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 -192
- zrb/task/llm/print_node.py +192 -64
- zrb/task/llm/prompt.py +198 -153
- zrb/task/llm/subagent_conversation_history.py +41 -0
- zrb/task/llm/tool_confirmation_completer.py +41 -0
- zrb/task/llm/tool_wrapper.py +216 -55
- zrb/task/llm/workflow.py +76 -0
- zrb/task/llm_task.py +122 -70
- 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 +27 -11
- 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.13.1.dist-info → zrb-1.21.33.dist-info}/METADATA +40 -20
- {zrb-1.13.1.dist-info → zrb-1.21.33.dist-info}/RECORD +114 -83
- {zrb-1.13.1.dist-info → zrb-1.21.33.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.33.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,29 @@ 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
|
+
**EFFICIENCY TIP:**
|
|
93
|
+
Do NOT use this tool if you already know the file path (e.g., from the user's prompt).
|
|
94
|
+
Use `read_from_file` directly in that case. Only use this to explore directory structures.
|
|
95
|
+
|
|
96
|
+
Example:
|
|
97
|
+
list_files(path='src', include_hidden=False, depth=2)
|
|
95
98
|
|
|
96
99
|
Args:
|
|
97
|
-
path (str
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
100
|
+
path (str): Directory path. Defaults to current directory.
|
|
101
|
+
include_hidden (bool): Include hidden files. Defaults to False.
|
|
102
|
+
depth (int): Maximum depth to traverse. Defaults to 3.
|
|
103
|
+
Minimum depth is 1 (current directory only).
|
|
104
|
+
excluded_patterns (list[str]): Glob patterns to exclude.
|
|
101
105
|
|
|
102
106
|
Returns:
|
|
103
|
-
|
|
104
|
-
Example: '{"files": ["src/main.py", "README.md"]}'
|
|
105
|
-
Raises:
|
|
106
|
-
FileNotFoundError: If the specified path does not exist.
|
|
107
|
+
dict: {'files': [relative_paths]}
|
|
107
108
|
"""
|
|
108
109
|
all_files: list[str] = []
|
|
109
110
|
abs_path = os.path.abspath(os.path.expanduser(path))
|
|
@@ -117,50 +118,30 @@ def list_files(
|
|
|
117
118
|
if excluded_patterns is not None
|
|
118
119
|
else DEFAULT_EXCLUDED_PATTERNS
|
|
119
120
|
)
|
|
121
|
+
if depth <= 0:
|
|
122
|
+
depth = 1
|
|
120
123
|
try:
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
full_path = os.path.join(root, filename)
|
|
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
|
|
124
|
+
initial_depth = abs_path.rstrip(os.sep).count(os.sep)
|
|
125
|
+
for root, dirs, files in os.walk(abs_path, topdown=True):
|
|
126
|
+
current_depth = root.rstrip(os.sep).count(os.sep) - initial_depth
|
|
127
|
+
if current_depth >= depth - 1:
|
|
128
|
+
del dirs[:]
|
|
129
|
+
dirs[:] = [
|
|
130
|
+
d
|
|
131
|
+
for d in dirs
|
|
132
|
+
if (include_hidden or not _is_hidden(d))
|
|
133
|
+
and not is_excluded(d, patterns_to_exclude)
|
|
134
|
+
]
|
|
135
|
+
for filename in files:
|
|
136
|
+
if (include_hidden or not _is_hidden(filename)) and not is_excluded(
|
|
137
|
+
filename, patterns_to_exclude
|
|
150
138
|
):
|
|
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
|
|
139
|
+
full_path = os.path.join(root, filename)
|
|
140
|
+
rel_full_path = os.path.relpath(full_path, abs_path)
|
|
141
|
+
if not is_excluded(rel_full_path, patterns_to_exclude):
|
|
142
|
+
all_files.append(rel_full_path)
|
|
143
|
+
return {"files": sorted(all_files)}
|
|
144
|
+
|
|
164
145
|
except (OSError, IOError) as e:
|
|
165
146
|
raise OSError(f"Error listing files in {path}: {e}")
|
|
166
147
|
except Exception as e:
|
|
@@ -197,97 +178,152 @@ def is_excluded(name: str, patterns: list[str]) -> bool:
|
|
|
197
178
|
|
|
198
179
|
|
|
199
180
|
def read_from_file(
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
end_line: Optional[int] = None,
|
|
203
|
-
) -> str:
|
|
181
|
+
file: FileToRead | list[FileToRead],
|
|
182
|
+
) -> dict[str, Any]:
|
|
204
183
|
"""
|
|
205
|
-
Reads
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
184
|
+
Reads content from one or more files, optionally specifying line ranges.
|
|
185
|
+
|
|
186
|
+
**EFFICIENCY TIP:**
|
|
187
|
+
For source code or configuration files, prefer reading the **entire file** at once
|
|
188
|
+
to ensure you have full context (imports, class definitions, etc.).
|
|
189
|
+
Only use `start_line` and `end_line` for extremely large files (like logs) or
|
|
190
|
+
when you are certain only a specific section is needed.
|
|
191
|
+
|
|
192
|
+
Examples:
|
|
193
|
+
```
|
|
194
|
+
# Read entire content of a single file
|
|
195
|
+
read_from_file(file={'path': 'path/to/file.txt'})
|
|
196
|
+
|
|
197
|
+
# Read specific lines from a file
|
|
198
|
+
# The content will be returned with line numbers in the format: "LINE_NUMBER | line content"
|
|
199
|
+
read_from_file(file={'path': 'path/to/large_file.log', 'start_line': 100, 'end_line': 150})
|
|
200
|
+
|
|
201
|
+
# Read multiple files
|
|
202
|
+
read_from_file(file=[
|
|
203
|
+
{'path': 'path/to/file1.txt'},
|
|
204
|
+
{'path': 'path/to/file2.txt', 'start_line': 1, 'end_line': 5}
|
|
205
|
+
])
|
|
206
|
+
```
|
|
213
207
|
|
|
214
208
|
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.
|
|
209
|
+
file (FileToRead | list[FileToRead]): A single file configuration or a list of them.
|
|
218
210
|
|
|
219
211
|
Returns:
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
Raises:
|
|
223
|
-
FileNotFoundError: If the specified file does not exist.
|
|
212
|
+
dict: Content and metadata for a single file, or a dict of results for multiple files.
|
|
213
|
+
The `content` field in the returned dictionary will have line numbers in the format: "LINE_NUMBER | line content"
|
|
224
214
|
"""
|
|
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
|
-
|
|
215
|
+
is_list = isinstance(file, list)
|
|
216
|
+
files = file if is_list else [file]
|
|
217
|
+
|
|
218
|
+
results = {}
|
|
219
|
+
for file_config in files:
|
|
220
|
+
path = file_config["path"]
|
|
221
|
+
start_line = file_config.get("start_line", None)
|
|
222
|
+
end_line = file_config.get("end_line", None)
|
|
223
|
+
try:
|
|
224
|
+
abs_path = os.path.abspath(os.path.expanduser(path))
|
|
225
|
+
if not os.path.exists(abs_path):
|
|
226
|
+
raise FileNotFoundError(f"File not found: {path}")
|
|
227
|
+
|
|
228
|
+
content = read_file_with_line_numbers(abs_path)
|
|
229
|
+
lines = content.splitlines()
|
|
230
|
+
total_lines = len(lines)
|
|
231
|
+
|
|
232
|
+
start_idx = (start_line - 1) if start_line is not None else 0
|
|
233
|
+
end_idx = end_line if end_line is not None else total_lines
|
|
234
|
+
|
|
235
|
+
if start_idx < 0:
|
|
236
|
+
start_idx = 0
|
|
237
|
+
if end_idx > total_lines:
|
|
238
|
+
end_idx = total_lines
|
|
239
|
+
if start_idx > end_idx:
|
|
240
|
+
start_idx = end_idx
|
|
241
|
+
|
|
242
|
+
selected_lines = lines[start_idx:end_idx]
|
|
243
|
+
content_result = "\n".join(selected_lines)
|
|
244
|
+
|
|
245
|
+
results[path] = {
|
|
248
246
|
"path": path,
|
|
249
247
|
"content": content_result,
|
|
250
|
-
"start_line": start_idx + 1,
|
|
251
|
-
"end_line": end_idx,
|
|
248
|
+
"start_line": start_idx + 1,
|
|
249
|
+
"end_line": end_idx,
|
|
252
250
|
"total_lines": total_lines,
|
|
253
251
|
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
252
|
+
except Exception as e:
|
|
253
|
+
if not is_list:
|
|
254
|
+
if isinstance(e, (OSError, IOError)):
|
|
255
|
+
raise OSError(f"Error reading file {path}: {e}") from e
|
|
256
|
+
raise RuntimeError(f"Unexpected error reading file {path}: {e}") from e
|
|
257
|
+
results[path] = f"Error reading file: {e}"
|
|
258
|
+
|
|
259
|
+
if is_list:
|
|
260
|
+
return results
|
|
261
|
+
|
|
262
|
+
return results[files[0]["path"]]
|
|
259
263
|
|
|
260
264
|
|
|
261
265
|
def write_to_file(
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
) -> str:
|
|
266
|
+
file: FileToWrite | list[FileToWrite],
|
|
267
|
+
) -> str | dict[str, Any]:
|
|
265
268
|
"""
|
|
266
|
-
Writes content to
|
|
267
|
-
|
|
268
|
-
|
|
269
|
+
Writes content to one or more files, with options for overwrite, append, or exclusive
|
|
270
|
+
creation.
|
|
271
|
+
|
|
272
|
+
**CRITICAL - PREVENT JSON ERRORS:**
|
|
273
|
+
1. **ESCAPING:** Do NOT double-escape quotes.
|
|
274
|
+
- CORRECT: "content": "He said \"Hello\""
|
|
275
|
+
- WRONG: "content": "He said \\"Hello\\"" <-- This breaks JSON parsing!
|
|
276
|
+
2. **SIZE LIMIT:** Content MUST NOT exceed 4000 characters.
|
|
277
|
+
- Exceeding this causes truncation and EOF errors.
|
|
278
|
+
- Split larger content into multiple sequential calls (first 'w', then 'a').
|
|
279
|
+
|
|
280
|
+
Examples:
|
|
281
|
+
```
|
|
282
|
+
# Overwrite 'file.txt' with initial content
|
|
283
|
+
write_to_file(file={'path': 'path/to/file.txt', 'content': 'Initial content.'})
|
|
284
|
+
|
|
285
|
+
# Append a second chunk to 'file.txt' (note the newline at the beginning of the content)
|
|
286
|
+
write_to_file(file={'path': 'path/to/file.txt', 'content': '\nSecond chunk.', 'mode': 'a'})
|
|
287
|
+
|
|
288
|
+
# Write to multiple files
|
|
289
|
+
write_to_file(file=[
|
|
290
|
+
{'path': 'path/to/file1.txt', 'content': 'Content for file 1'},
|
|
291
|
+
{'path': 'path/to/file2.txt', 'content': 'Content for file 2', 'mode': 'w'}
|
|
292
|
+
])
|
|
293
|
+
```
|
|
269
294
|
|
|
270
295
|
Args:
|
|
271
|
-
|
|
272
|
-
content (str): The full, complete content to be written to the file. Do not use partial content or omit any lines.
|
|
296
|
+
file (FileToWrite | list[FileToWrite]): A single file configuration or a list of them.
|
|
273
297
|
|
|
274
298
|
Returns:
|
|
275
|
-
|
|
276
|
-
Example: '{"success": true, "path": "new_file.txt"}'
|
|
299
|
+
Success message for single file, or dict with success/errors for multiple files.
|
|
277
300
|
"""
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
301
|
+
# Normalize to list
|
|
302
|
+
files = file if isinstance(file, list) else [file]
|
|
303
|
+
|
|
304
|
+
success = []
|
|
305
|
+
errors = {}
|
|
306
|
+
for file_config in files:
|
|
307
|
+
path = file_config["path"]
|
|
308
|
+
content = file_config["content"]
|
|
309
|
+
mode = file_config.get("mode", "w")
|
|
310
|
+
try:
|
|
311
|
+
abs_path = os.path.abspath(os.path.expanduser(path))
|
|
312
|
+
# The underlying utility creates the directory, so we don't need to do it here.
|
|
313
|
+
write_file(abs_path, content, mode=mode)
|
|
314
|
+
success.append(path)
|
|
315
|
+
except Exception as e:
|
|
316
|
+
errors[path] = f"Error writing file: {e}"
|
|
317
|
+
|
|
318
|
+
# Return appropriate response based on input type
|
|
319
|
+
if isinstance(file, list):
|
|
320
|
+
return {"success": success, "errors": errors}
|
|
321
|
+
else:
|
|
322
|
+
if errors:
|
|
323
|
+
raise RuntimeError(
|
|
324
|
+
f"Error writing file {file['path']}: {errors[file['path']]}"
|
|
325
|
+
)
|
|
326
|
+
return f"Successfully wrote to file: {file['path']} in mode '{file.get('mode', 'w')}'"
|
|
291
327
|
|
|
292
328
|
|
|
293
329
|
def search_files(
|
|
@@ -295,22 +331,21 @@ def search_files(
|
|
|
295
331
|
regex: str,
|
|
296
332
|
file_pattern: Optional[str] = None,
|
|
297
333
|
include_hidden: bool = True,
|
|
298
|
-
) -> str:
|
|
334
|
+
) -> dict[str, Any]:
|
|
299
335
|
"""
|
|
300
|
-
Searches for a
|
|
336
|
+
Searches for a regex pattern in files within a directory.
|
|
301
337
|
|
|
302
|
-
|
|
338
|
+
Example:
|
|
339
|
+
search_files(path='src', regex='class \\w+', file_pattern='*.py', include_hidden=False)
|
|
303
340
|
|
|
304
341
|
Args:
|
|
305
|
-
path (str):
|
|
306
|
-
regex (str):
|
|
307
|
-
file_pattern (str
|
|
308
|
-
include_hidden (bool
|
|
342
|
+
path (str): Directory to search.
|
|
343
|
+
regex (str): Regex pattern.
|
|
344
|
+
file_pattern (str): Glob pattern filter.
|
|
345
|
+
include_hidden (bool): Include hidden files.
|
|
309
346
|
|
|
310
347
|
Returns:
|
|
311
|
-
|
|
312
|
-
Raises:
|
|
313
|
-
ValueError: If the provided `regex` pattern is invalid.
|
|
348
|
+
dict: Summary and list of matches.
|
|
314
349
|
"""
|
|
315
350
|
try:
|
|
316
351
|
pattern = re.compile(regex)
|
|
@@ -357,9 +392,7 @@ def search_files(
|
|
|
357
392
|
f"Found {match_count} matches in {file_match_count} files "
|
|
358
393
|
f"(searched {searched_file_count} files)."
|
|
359
394
|
)
|
|
360
|
-
return
|
|
361
|
-
search_results
|
|
362
|
-
) # No need for pretty printing for LLM consumption
|
|
395
|
+
return search_results
|
|
363
396
|
except (OSError, IOError) as e:
|
|
364
397
|
raise OSError(f"Error searching files in {path}: {e}")
|
|
365
398
|
except Exception as e:
|
|
@@ -367,7 +400,9 @@ def search_files(
|
|
|
367
400
|
|
|
368
401
|
|
|
369
402
|
def _get_file_matches(
|
|
370
|
-
file_path: str,
|
|
403
|
+
file_path: str,
|
|
404
|
+
pattern: re.Pattern,
|
|
405
|
+
context_lines: int = 2,
|
|
371
406
|
) -> list[dict[str, Any]]:
|
|
372
407
|
"""Search for regex matches in a file with context."""
|
|
373
408
|
try:
|
|
@@ -398,143 +433,145 @@ def _get_file_matches(
|
|
|
398
433
|
|
|
399
434
|
|
|
400
435
|
def replace_in_file(
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
new_string: str,
|
|
404
|
-
) -> str:
|
|
436
|
+
file: FileReplacement | list[FileReplacement],
|
|
437
|
+
) -> str | dict[str, Any]:
|
|
405
438
|
"""
|
|
406
|
-
Replaces
|
|
407
|
-
|
|
408
|
-
|
|
439
|
+
Replaces exact text in files.
|
|
440
|
+
|
|
441
|
+
**CRITICAL INSTRUCTIONS:**
|
|
442
|
+
1. **READ FIRST:** Use `read_file` to get exact content. Do not guess.
|
|
443
|
+
2. **EXACT MATCH:** `old_text` must match file content EXACTLY (whitespace, newlines).
|
|
444
|
+
3. **ESCAPING:** Do NOT double-escape quotes in `new_text`. Use `\"`, not `\\"`.
|
|
445
|
+
4. **SIZE LIMIT:** `new_text` MUST NOT exceed 4000 chars to avoid truncation/EOF errors.
|
|
446
|
+
5. **MINIMAL CONTEXT:** Keep `old_text` small (target lines + 2-3 context lines).
|
|
447
|
+
6. **DEFAULT:** Replaces **ALL** occurrences. Set `count=1` for first occurrence only.
|
|
448
|
+
|
|
449
|
+
Examples:
|
|
450
|
+
```
|
|
451
|
+
# Replace ALL occurrences
|
|
452
|
+
replace_in_file(file=[
|
|
453
|
+
{'path': 'file.txt', 'old_text': 'foo', 'new_text': 'bar'},
|
|
454
|
+
{'path': 'file.txt', 'old_text': 'baz', 'new_text': 'qux'}
|
|
455
|
+
])
|
|
456
|
+
|
|
457
|
+
# Replace ONLY the first occurrence
|
|
458
|
+
replace_in_file(
|
|
459
|
+
file={'path': 'file.txt', 'old_text': 'foo', 'new_text': 'bar', 'count': 1}
|
|
460
|
+
)
|
|
409
461
|
|
|
410
|
-
|
|
462
|
+
# Replace code block (include context for safety)
|
|
463
|
+
replace_in_file(
|
|
464
|
+
file={
|
|
465
|
+
'path': 'app.py',
|
|
466
|
+
'old_text': ' def old_fn():\n pass',
|
|
467
|
+
'new_text': ' def new_fn():\n pass'
|
|
468
|
+
}
|
|
469
|
+
)
|
|
470
|
+
```
|
|
411
471
|
|
|
412
472
|
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`.
|
|
473
|
+
file: Single replacement config or list of them.
|
|
416
474
|
|
|
417
475
|
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.
|
|
476
|
+
Success message or error dict.
|
|
422
477
|
"""
|
|
423
|
-
|
|
424
|
-
if
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
478
|
+
# Normalize to list
|
|
479
|
+
file_replacements = file if isinstance(file, list) else [file]
|
|
480
|
+
# Group replacements by file path to minimize file I/O
|
|
481
|
+
replacements_by_path: dict[str, list[FileReplacement]] = {}
|
|
482
|
+
for r in file_replacements:
|
|
483
|
+
path = r["path"]
|
|
484
|
+
if path not in replacements_by_path:
|
|
485
|
+
replacements_by_path[path] = []
|
|
486
|
+
replacements_by_path[path].append(r)
|
|
487
|
+
success = []
|
|
488
|
+
errors = {}
|
|
489
|
+
for path, replacements in replacements_by_path.items():
|
|
490
|
+
try:
|
|
491
|
+
abs_path = os.path.abspath(os.path.expanduser(path))
|
|
492
|
+
if not os.path.exists(abs_path):
|
|
493
|
+
raise FileNotFoundError(f"File not found: {path}")
|
|
494
|
+
content = read_file(abs_path)
|
|
495
|
+
original_content = content
|
|
496
|
+
# Apply all replacements for this file
|
|
497
|
+
for replacement in replacements:
|
|
498
|
+
old_text = replacement["old_text"]
|
|
499
|
+
new_text = replacement["new_text"]
|
|
500
|
+
count = replacement.get("count", -1)
|
|
501
|
+
if old_text not in content:
|
|
502
|
+
raise ValueError(f"old_text not found in file: {path}")
|
|
503
|
+
# Replace occurrences
|
|
504
|
+
content = content.replace(old_text, new_text, count)
|
|
505
|
+
# Only write if content actually changed
|
|
506
|
+
if content != original_content:
|
|
507
|
+
write_file(abs_path, content)
|
|
508
|
+
success.append(path)
|
|
509
|
+
else:
|
|
510
|
+
success.append(f"{path} (no changes needed)")
|
|
511
|
+
except Exception as e:
|
|
512
|
+
errors[path] = f"Error applying replacement to {path}: {e}"
|
|
513
|
+
# Return appropriate response based on input type
|
|
514
|
+
if isinstance(file, list):
|
|
515
|
+
return {"success": success, "errors": errors}
|
|
516
|
+
path = file["path"]
|
|
517
|
+
if errors:
|
|
518
|
+
error_message = errors[path]
|
|
519
|
+
raise RuntimeError(f"Error applying replacement to {path}: {error_message}")
|
|
520
|
+
return f"Successfully applied replacement(s) to {path}"
|
|
439
521
|
|
|
440
522
|
|
|
441
523
|
async def analyze_file(
|
|
442
|
-
ctx: AnyContext, path: str, query: str,
|
|
443
|
-
) -> str:
|
|
524
|
+
ctx: AnyContext, path: str, query: str, token_threshold: int | None = None
|
|
525
|
+
) -> dict[str, Any]:
|
|
444
526
|
"""
|
|
445
|
-
|
|
527
|
+
Analyzes a file using a sub-agent for complex questions.
|
|
446
528
|
|
|
447
|
-
|
|
529
|
+
CRITICAL: The query must contain ALL necessary context, instructions, and information.
|
|
530
|
+
The sub-agent performing the analysis does NOT share your current conversation
|
|
531
|
+
history, memory, or global context.
|
|
532
|
+
The quality of analysis depends entirely on the query. Vague queries yield poor
|
|
533
|
+
results.
|
|
448
534
|
|
|
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?".
|
|
535
|
+
Example:
|
|
536
|
+
analyze_file(path='src/main.py', query='Summarize the main function.')
|
|
454
537
|
|
|
455
538
|
Args:
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
539
|
+
ctx (AnyContext): The execution context.
|
|
540
|
+
path (str): The path to the file to analyze.
|
|
541
|
+
query (str): A specific analysis query with clear guidelines and
|
|
542
|
+
necessary information.
|
|
543
|
+
token_threshold (int | None): Max tokens.
|
|
459
544
|
|
|
460
545
|
Returns:
|
|
461
|
-
|
|
462
|
-
Raises:
|
|
463
|
-
FileNotFoundError: If the specified file does not exist.
|
|
546
|
+
Analysis results.
|
|
464
547
|
"""
|
|
465
|
-
if
|
|
466
|
-
|
|
548
|
+
if token_threshold is None:
|
|
549
|
+
token_threshold = CFG.LLM_FILE_ANALYSIS_TOKEN_THRESHOLD
|
|
467
550
|
abs_path = os.path.abspath(os.path.expanduser(path))
|
|
468
551
|
if not os.path.exists(abs_path):
|
|
469
552
|
raise FileNotFoundError(f"File not found: {path}")
|
|
470
553
|
file_content = read_file(abs_path)
|
|
471
554
|
_analyze_file = create_sub_agent_tool(
|
|
472
555
|
tool_name="analyze_file",
|
|
473
|
-
tool_description=
|
|
556
|
+
tool_description=(
|
|
557
|
+
"Analyze file content using LLM sub-agent "
|
|
558
|
+
"for complex questions about code structure, documentation "
|
|
559
|
+
"quality, or file-specific analysis. Use for questions that "
|
|
560
|
+
"require understanding beyond simple text reading."
|
|
561
|
+
),
|
|
474
562
|
system_prompt=CFG.LLM_FILE_EXTRACTOR_SYSTEM_PROMPT,
|
|
475
563
|
tools=[read_from_file, search_files],
|
|
564
|
+
auto_summarize=False,
|
|
565
|
+
remember_history=False,
|
|
566
|
+
yolo_mode=True,
|
|
476
567
|
)
|
|
477
568
|
payload = json.dumps(
|
|
478
|
-
{
|
|
569
|
+
{
|
|
570
|
+
"instruction": query,
|
|
571
|
+
"file_path": abs_path,
|
|
572
|
+
"file_content": llm_rate_limitter.clip_prompt(
|
|
573
|
+
file_content, token_threshold
|
|
574
|
+
),
|
|
575
|
+
}
|
|
479
576
|
)
|
|
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
|
|
577
|
+
return await _analyze_file(ctx, payload)
|