zrb 1.8.10__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 +126 -113
- zrb/__main__.py +1 -1
- zrb/attr/type.py +10 -7
- zrb/builtin/__init__.py +2 -50
- 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 +274 -0
- zrb/builtin/llm/chat_session.py +152 -85
- zrb/builtin/llm/chat_session_cmd.py +288 -0
- zrb/builtin/llm/chat_trigger.py +79 -0
- zrb/builtin/llm/history.py +7 -9
- zrb/builtin/llm/llm_ask.py +221 -98
- zrb/builtin/llm/tool/api.py +74 -52
- zrb/builtin/llm/tool/cli.py +46 -17
- zrb/builtin/llm/tool/code.py +71 -90
- zrb/builtin/llm/tool/file.py +301 -241
- zrb/builtin/llm/tool/note.py +84 -0
- zrb/builtin/llm/tool/rag.py +38 -8
- zrb/builtin/llm/tool/sub_agent.py +67 -50
- zrb/builtin/llm/tool/web.py +146 -122
- 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/builtin/todo.py +13 -2
- zrb/config/config.py +614 -0
- zrb/config/default_prompt/file_extractor_system_prompt.md +112 -0
- zrb/config/default_prompt/interactive_system_prompt.md +29 -0
- zrb/config/default_prompt/persona.md +1 -0
- zrb/config/default_prompt/repo_extractor_system_prompt.md +112 -0
- zrb/config/default_prompt/repo_summarizer_system_prompt.md +29 -0
- zrb/config/default_prompt/summarization_prompt.md +57 -0
- zrb/config/default_prompt/system_prompt.md +38 -0
- zrb/config/llm_config.py +339 -0
- zrb/config/llm_context/config.py +166 -0
- zrb/config/llm_context/config_parser.py +40 -0
- zrb/config/llm_context/workflow.py +81 -0
- zrb/config/llm_rate_limitter.py +190 -0
- zrb/{runner → config}/web_auth_config.py +17 -22
- zrb/context/any_shared_context.py +17 -1
- zrb/context/context.py +16 -2
- zrb/context/shared_context.py +18 -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 +8 -25
- zrb/runner/cli.py +25 -23
- zrb/runner/common_util.py +24 -19
- zrb/runner/web_app.py +3 -3
- zrb/runner/web_route/docs_route.py +1 -1
- zrb/runner/web_route/error_page/serve_default_404.py +1 -1
- zrb/runner/web_route/error_page/show_error_page.py +1 -1
- zrb/runner/web_route/home_page/home_page_route.py +2 -2
- zrb/runner/web_route/login_api_route.py +1 -1
- zrb/runner/web_route/login_page/login_page_route.py +2 -2
- zrb/runner/web_route/logout_api_route.py +1 -1
- zrb/runner/web_route/logout_page/logout_page_route.py +2 -2
- zrb/runner/web_route/node_page/group/show_group_page.py +1 -1
- zrb/runner/web_route/node_page/node_page_route.py +1 -1
- zrb/runner/web_route/node_page/task/show_task_page.py +1 -1
- zrb/runner/web_route/refresh_token_api_route.py +1 -1
- zrb/runner/web_route/static/static_route.py +1 -1
- zrb/runner/web_route/task_input_api_route.py +6 -6
- zrb/runner/web_route/task_session_api_route.py +20 -12
- zrb/runner/web_util/cookie.py +1 -1
- zrb/runner/web_util/token.py +1 -1
- zrb/runner/web_util/user.py +8 -4
- zrb/session/any_session.py +24 -17
- zrb/session/session.py +50 -25
- zrb/session_state_logger/any_session_state_logger.py +9 -4
- zrb/session_state_logger/file_session_state_logger.py +16 -6
- zrb/session_state_logger/session_state_logger_factory.py +1 -1
- zrb/task/any_task.py +30 -9
- 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/cmd_task.py +1 -1
- zrb/task/llm/agent.py +154 -161
- zrb/task/llm/agent_runner.py +152 -0
- zrb/task/llm/config.py +47 -18
- zrb/task/llm/conversation_history.py +209 -0
- zrb/task/llm/conversation_history_model.py +67 -0
- 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/error.py +24 -10
- 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 +11 -166
- zrb/task/llm/print_node.py +193 -69
- zrb/task/llm/prompt.py +242 -45
- zrb/task/llm/subagent_conversation_history.py +41 -0
- zrb/task/llm/tool_wrapper.py +260 -57
- zrb/task/llm/workflow.py +76 -0
- zrb/task/llm_task.py +182 -171
- zrb/task/make_task.py +2 -3
- zrb/task/rsync_task.py +26 -11
- zrb/task/scheduler.py +4 -4
- zrb/util/attr.py +54 -39
- zrb/util/callable.py +23 -0
- zrb/util/cli/markdown.py +12 -0
- zrb/util/cli/text.py +30 -0
- zrb/util/file.py +29 -11
- zrb/util/git.py +8 -11
- zrb/util/git_diff_model.py +10 -0
- zrb/util/git_subtree.py +9 -14
- zrb/util/git_subtree_model.py +32 -0
- zrb/util/init_path.py +1 -1
- zrb/util/markdown.py +62 -0
- zrb/util/string/conversion.py +2 -2
- zrb/util/todo.py +17 -50
- zrb/util/todo_model.py +46 -0
- zrb/util/truncate.py +23 -0
- zrb/util/yaml.py +204 -0
- zrb/xcom/xcom.py +10 -0
- zrb-1.21.29.dist-info/METADATA +270 -0
- {zrb-1.8.10.dist-info → zrb-1.21.29.dist-info}/RECORD +140 -98
- {zrb-1.8.10.dist-info → zrb-1.21.29.dist-info}/WHEEL +1 -1
- zrb/config.py +0 -335
- zrb/llm_config.py +0 -411
- zrb/llm_rate_limitter.py +0 -125
- zrb/task/llm/context.py +0 -102
- zrb/task/llm/context_enrichment.py +0 -199
- zrb/task/llm/history.py +0 -211
- zrb-1.8.10.dist-info/METADATA +0 -264
- {zrb-1.8.10.dist-info → zrb-1.21.29.dist-info}/entry_points.txt +0 -0
zrb/builtin/llm/tool/file.py
CHANGED
|
@@ -5,32 +5,12 @@ import re
|
|
|
5
5
|
from typing import Any, Optional
|
|
6
6
|
|
|
7
7
|
from zrb.builtin.llm.tool.sub_agent import create_sub_agent_tool
|
|
8
|
+
from zrb.config.config import CFG
|
|
9
|
+
from zrb.config.llm_rate_limitter import llm_rate_limitter
|
|
8
10
|
from zrb.context.any_context import AnyContext
|
|
9
|
-
from zrb.
|
|
11
|
+
from zrb.task.llm.file_tool_model import FileReplacement, FileToRead, FileToWrite
|
|
10
12
|
from zrb.util.file import read_file, read_file_with_line_numbers, write_file
|
|
11
13
|
|
|
12
|
-
_EXTRACT_INFO_FROM_FILE_SYSTEM_PROMPT = """
|
|
13
|
-
You are an extraction info agent.
|
|
14
|
-
Your goal is to help to extract relevant information to help the main assistant.
|
|
15
|
-
You write your output is in markdown format containing path and relevant information.
|
|
16
|
-
Extract only information that relevant to main assistant's goal.
|
|
17
|
-
|
|
18
|
-
Extracted Information format (Use this as reference, extract relevant information only):
|
|
19
|
-
# imports
|
|
20
|
-
- <imported-package>
|
|
21
|
-
- ...
|
|
22
|
-
# variables
|
|
23
|
-
- <variable-type> <variable-name>: <the-purpose-of-the-variable>
|
|
24
|
-
- ...
|
|
25
|
-
# functions
|
|
26
|
-
- <function-name>:
|
|
27
|
-
- parameters: <parameters>
|
|
28
|
-
- logic/description: <what-the-function-do-and-how-it-works>
|
|
29
|
-
...
|
|
30
|
-
...
|
|
31
|
-
""".strip()
|
|
32
|
-
|
|
33
|
-
|
|
34
14
|
DEFAULT_EXCLUDED_PATTERNS = [
|
|
35
15
|
# Common Python artifacts
|
|
36
16
|
"__pycache__",
|
|
@@ -102,21 +82,25 @@ DEFAULT_EXCLUDED_PATTERNS = [
|
|
|
102
82
|
|
|
103
83
|
def list_files(
|
|
104
84
|
path: str = ".",
|
|
105
|
-
recursive: bool = True,
|
|
106
85
|
include_hidden: bool = False,
|
|
86
|
+
depth: int = 3,
|
|
107
87
|
excluded_patterns: Optional[list[str]] = None,
|
|
108
|
-
) -> str:
|
|
109
|
-
"""
|
|
88
|
+
) -> dict[str, list[str]]:
|
|
89
|
+
"""
|
|
90
|
+
Lists files recursively up to a specified depth.
|
|
91
|
+
|
|
92
|
+
Example:
|
|
93
|
+
list_files(path='src', include_hidden=False, depth=2)
|
|
94
|
+
|
|
110
95
|
Args:
|
|
111
|
-
path (str):
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
+
|
|
116
102
|
Returns:
|
|
117
|
-
|
|
118
|
-
Raises:
|
|
119
|
-
Exception: If an error occurs.
|
|
103
|
+
dict: {'files': [relative_paths]}
|
|
120
104
|
"""
|
|
121
105
|
all_files: list[str] = []
|
|
122
106
|
abs_path = os.path.abspath(os.path.expanduser(path))
|
|
@@ -130,50 +114,30 @@ def list_files(
|
|
|
130
114
|
if excluded_patterns is not None
|
|
131
115
|
else DEFAULT_EXCLUDED_PATTERNS
|
|
132
116
|
)
|
|
117
|
+
if depth <= 0:
|
|
118
|
+
depth = 1
|
|
133
119
|
try:
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
full_path = os.path.join(root, filename)
|
|
149
|
-
# Check rel path for patterns like '**/node_modules/*'
|
|
150
|
-
rel_full_path = os.path.relpath(full_path, abs_path)
|
|
151
|
-
is_rel_path_excluded = is_excluded(
|
|
152
|
-
rel_full_path, patterns_to_exclude
|
|
153
|
-
)
|
|
154
|
-
if not is_rel_path_excluded:
|
|
155
|
-
all_files.append(full_path)
|
|
156
|
-
else:
|
|
157
|
-
# Non-recursive listing (top-level only)
|
|
158
|
-
for item in os.listdir(abs_path):
|
|
159
|
-
full_path = os.path.join(abs_path, item)
|
|
160
|
-
# Include both files and directories if not recursive
|
|
161
|
-
if (include_hidden or not _is_hidden(item)) and not is_excluded(
|
|
162
|
-
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
|
|
163
134
|
):
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
ValueError
|
|
171
|
-
) as e: # Handle case where path is '.' and abs_path is CWD root
|
|
172
|
-
if "path is on mount '" in str(e) and "' which is not on mount '" in str(e):
|
|
173
|
-
# If paths are on different mounts, just use absolute paths
|
|
174
|
-
rel_files = all_files
|
|
175
|
-
return json.dumps({"files": sorted(rel_files)})
|
|
176
|
-
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
|
+
|
|
177
141
|
except (OSError, IOError) as e:
|
|
178
142
|
raise OSError(f"Error listing files in {path}: {e}")
|
|
179
143
|
except Exception as e:
|
|
@@ -210,97 +174,146 @@ def is_excluded(name: str, patterns: list[str]) -> bool:
|
|
|
210
174
|
|
|
211
175
|
|
|
212
176
|
def read_from_file(
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
177
|
+
file: FileToRead | list[FileToRead],
|
|
178
|
+
) -> dict[str, Any]:
|
|
179
|
+
"""
|
|
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
|
+
|
|
218
198
|
Args:
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
Defaults to None (start of file).
|
|
222
|
-
end_line (Optional[int]): Ending line number (1-based, inclusive).
|
|
223
|
-
Defaults to None (end of file).
|
|
199
|
+
file (FileToRead | list[FileToRead]): A single file configuration or a list of them.
|
|
200
|
+
|
|
224
201
|
Returns:
|
|
225
|
-
|
|
226
|
-
The content
|
|
227
|
-
Raises:
|
|
228
|
-
Exception: If an error occurs.
|
|
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"
|
|
229
204
|
"""
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
start_idx =
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
205
|
+
is_list = isinstance(file, list)
|
|
206
|
+
files = file if is_list else [file]
|
|
207
|
+
|
|
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] = {
|
|
253
236
|
"path": path,
|
|
254
237
|
"content": content_result,
|
|
255
|
-
"start_line": start_idx + 1,
|
|
256
|
-
"end_line": end_idx,
|
|
238
|
+
"start_line": start_idx + 1,
|
|
239
|
+
"end_line": end_idx,
|
|
257
240
|
"total_lines": total_lines,
|
|
258
241
|
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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"]]
|
|
264
253
|
|
|
265
254
|
|
|
266
255
|
def write_to_file(
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
256
|
+
file: FileToWrite | list[FileToWrite],
|
|
257
|
+
) -> str | dict[str, Any]:
|
|
258
|
+
"""
|
|
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
|
+
```
|
|
284
|
+
|
|
272
285
|
Args:
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
MUST be complete, no truncation/omissions. Exclude line numbers.
|
|
276
|
-
line_count (int): Number of lines in the provided content.
|
|
286
|
+
file (FileToWrite | list[FileToWrite]): A single file configuration or a list of them.
|
|
287
|
+
|
|
277
288
|
Returns:
|
|
278
|
-
|
|
279
|
-
Raises:
|
|
280
|
-
Exception: If an error occurs.
|
|
289
|
+
Success message for single file, or dict with success/errors for multiple files.
|
|
281
290
|
"""
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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')}'"
|
|
304
317
|
|
|
305
318
|
|
|
306
319
|
def search_files(
|
|
@@ -308,18 +321,21 @@ def search_files(
|
|
|
308
321
|
regex: str,
|
|
309
322
|
file_pattern: Optional[str] = None,
|
|
310
323
|
include_hidden: bool = True,
|
|
311
|
-
) -> str:
|
|
312
|
-
"""
|
|
324
|
+
) -> dict[str, Any]:
|
|
325
|
+
"""
|
|
326
|
+
Searches for a regex pattern in files within a directory.
|
|
327
|
+
|
|
328
|
+
Example:
|
|
329
|
+
search_files(path='src', regex='class \\w+', file_pattern='*.py', include_hidden=False)
|
|
330
|
+
|
|
313
331
|
Args:
|
|
314
|
-
path (str):
|
|
315
|
-
regex (str):
|
|
316
|
-
file_pattern (
|
|
317
|
-
|
|
318
|
-
|
|
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.
|
|
336
|
+
|
|
319
337
|
Returns:
|
|
320
|
-
|
|
321
|
-
Raises:
|
|
322
|
-
Exception: If error occurs or regex is invalid.
|
|
338
|
+
dict: Summary and list of matches.
|
|
323
339
|
"""
|
|
324
340
|
try:
|
|
325
341
|
pattern = re.compile(regex)
|
|
@@ -366,9 +382,7 @@ def search_files(
|
|
|
366
382
|
f"Found {match_count} matches in {file_match_count} files "
|
|
367
383
|
f"(searched {searched_file_count} files)."
|
|
368
384
|
)
|
|
369
|
-
return
|
|
370
|
-
search_results
|
|
371
|
-
) # No need for pretty printing for LLM consumption
|
|
385
|
+
return search_results
|
|
372
386
|
except (OSError, IOError) as e:
|
|
373
387
|
raise OSError(f"Error searching files in {path}: {e}")
|
|
374
388
|
except Exception as e:
|
|
@@ -376,7 +390,9 @@ def search_files(
|
|
|
376
390
|
|
|
377
391
|
|
|
378
392
|
def _get_file_matches(
|
|
379
|
-
file_path: str,
|
|
393
|
+
file_path: str,
|
|
394
|
+
pattern: re.Pattern,
|
|
395
|
+
context_lines: int = 2,
|
|
380
396
|
) -> list[dict[str, Any]]:
|
|
381
397
|
"""Search for regex matches in a file with context."""
|
|
382
398
|
try:
|
|
@@ -406,95 +422,139 @@ def _get_file_matches(
|
|
|
406
422
|
raise RuntimeError(f"Unexpected error processing {file_path}: {e}")
|
|
407
423
|
|
|
408
424
|
|
|
409
|
-
def
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
425
|
+
def replace_in_file(
|
|
426
|
+
file: FileReplacement | list[FileReplacement],
|
|
427
|
+
) -> str | dict[str, Any]:
|
|
428
|
+
"""
|
|
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
|
+
)
|
|
451
|
+
|
|
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
|
+
```
|
|
461
|
+
|
|
417
462
|
Args:
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
end_line (int): The 1-based ending line number (inclusive) of the content to replace.
|
|
421
|
-
search_content (str): The exact content expected to be found in the specified
|
|
422
|
-
line range. Must exactly match file content including whitespace/indentation,
|
|
423
|
-
excluding line numbers.
|
|
424
|
-
replace_content (str): The new content to replace the search_content with.
|
|
425
|
-
Excluding line numbers.
|
|
463
|
+
file: Single replacement config or list of them.
|
|
464
|
+
|
|
426
465
|
Returns:
|
|
427
|
-
|
|
428
|
-
Raises:
|
|
429
|
-
Exception: If an error occurs.
|
|
466
|
+
Success message or error dict.
|
|
430
467
|
"""
|
|
431
|
-
|
|
432
|
-
if
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
if
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
f"
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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}"
|
|
466
511
|
|
|
467
512
|
|
|
468
513
|
async def analyze_file(
|
|
469
|
-
ctx: AnyContext, path: str, query: str,
|
|
470
|
-
) -> str:
|
|
471
|
-
"""
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
514
|
+
ctx: AnyContext, path: str, query: str, token_threshold: int | None = None
|
|
515
|
+
) -> dict[str, Any]:
|
|
516
|
+
"""
|
|
517
|
+
Analyzes a file using a sub-agent for complex questions.
|
|
518
|
+
|
|
519
|
+
Example:
|
|
520
|
+
analyze_file(path='src/main.py', query='Summarize the main function.')
|
|
521
|
+
|
|
477
522
|
Args:
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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.
|
|
528
|
+
|
|
481
529
|
Returns:
|
|
482
|
-
|
|
483
|
-
Raises:
|
|
484
|
-
Exception: If an error occurs.
|
|
530
|
+
Analysis results.
|
|
485
531
|
"""
|
|
532
|
+
if token_threshold is None:
|
|
533
|
+
token_threshold = CFG.LLM_FILE_ANALYSIS_TOKEN_THRESHOLD
|
|
486
534
|
abs_path = os.path.abspath(os.path.expanduser(path))
|
|
487
535
|
if not os.path.exists(abs_path):
|
|
488
536
|
raise FileNotFoundError(f"File not found: {path}")
|
|
489
537
|
file_content = read_file(abs_path)
|
|
490
538
|
_analyze_file = create_sub_agent_tool(
|
|
491
539
|
tool_name="analyze_file",
|
|
492
|
-
tool_description=
|
|
493
|
-
|
|
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
|
+
),
|
|
546
|
+
system_prompt=CFG.LLM_FILE_EXTRACTOR_SYSTEM_PROMPT,
|
|
494
547
|
tools=[read_from_file, search_files],
|
|
548
|
+
auto_summarize=False,
|
|
549
|
+
remember_history=False,
|
|
495
550
|
)
|
|
496
551
|
payload = json.dumps(
|
|
497
|
-
{
|
|
552
|
+
{
|
|
553
|
+
"instruction": query,
|
|
554
|
+
"file_path": abs_path,
|
|
555
|
+
"file_content": llm_rate_limitter.clip_prompt(
|
|
556
|
+
file_content, token_threshold
|
|
557
|
+
),
|
|
558
|
+
}
|
|
498
559
|
)
|
|
499
|
-
|
|
500
|
-
return await _analyze_file(ctx, clipped_payload)
|
|
560
|
+
return await _analyze_file(ctx, payload)
|