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.

Files changed (108) hide show
  1. zrb/__init__.py +2 -6
  2. zrb/attr/type.py +10 -7
  3. zrb/builtin/__init__.py +2 -0
  4. zrb/builtin/git.py +12 -1
  5. zrb/builtin/group.py +31 -15
  6. zrb/builtin/llm/attachment.py +40 -0
  7. zrb/builtin/llm/chat_completion.py +274 -0
  8. zrb/builtin/llm/chat_session.py +126 -167
  9. zrb/builtin/llm/chat_session_cmd.py +288 -0
  10. zrb/builtin/llm/chat_trigger.py +79 -0
  11. zrb/builtin/llm/history.py +4 -4
  12. zrb/builtin/llm/llm_ask.py +217 -135
  13. zrb/builtin/llm/tool/api.py +74 -70
  14. zrb/builtin/llm/tool/cli.py +35 -21
  15. zrb/builtin/llm/tool/code.py +55 -73
  16. zrb/builtin/llm/tool/file.py +278 -344
  17. zrb/builtin/llm/tool/note.py +84 -0
  18. zrb/builtin/llm/tool/rag.py +27 -34
  19. zrb/builtin/llm/tool/sub_agent.py +54 -41
  20. zrb/builtin/llm/tool/web.py +74 -98
  21. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/_zrb/entity/add_entity_util.py +7 -7
  22. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/_zrb/module/add_module_util.py +5 -5
  23. zrb/builtin/project/add/fastapp/fastapp_util.py +1 -1
  24. zrb/builtin/searxng/config/settings.yml +5671 -0
  25. zrb/builtin/searxng/start.py +21 -0
  26. zrb/builtin/shell/autocomplete/bash.py +4 -3
  27. zrb/builtin/shell/autocomplete/zsh.py +4 -3
  28. zrb/config/config.py +202 -27
  29. zrb/config/default_prompt/file_extractor_system_prompt.md +109 -9
  30. zrb/config/default_prompt/interactive_system_prompt.md +24 -30
  31. zrb/config/default_prompt/persona.md +1 -1
  32. zrb/config/default_prompt/repo_extractor_system_prompt.md +31 -31
  33. zrb/config/default_prompt/repo_summarizer_system_prompt.md +27 -8
  34. zrb/config/default_prompt/summarization_prompt.md +57 -16
  35. zrb/config/default_prompt/system_prompt.md +36 -30
  36. zrb/config/llm_config.py +119 -23
  37. zrb/config/llm_context/config.py +127 -90
  38. zrb/config/llm_context/config_parser.py +1 -7
  39. zrb/config/llm_context/workflow.py +81 -0
  40. zrb/config/llm_rate_limitter.py +100 -47
  41. zrb/context/any_shared_context.py +7 -1
  42. zrb/context/context.py +8 -2
  43. zrb/context/shared_context.py +3 -7
  44. zrb/group/any_group.py +3 -3
  45. zrb/group/group.py +3 -3
  46. zrb/input/any_input.py +5 -1
  47. zrb/input/base_input.py +18 -6
  48. zrb/input/option_input.py +13 -1
  49. zrb/input/text_input.py +7 -24
  50. zrb/runner/cli.py +21 -20
  51. zrb/runner/common_util.py +24 -19
  52. zrb/runner/web_route/task_input_api_route.py +5 -5
  53. zrb/runner/web_util/user.py +7 -3
  54. zrb/session/any_session.py +12 -6
  55. zrb/session/session.py +39 -18
  56. zrb/task/any_task.py +24 -3
  57. zrb/task/base/context.py +17 -9
  58. zrb/task/base/execution.py +15 -8
  59. zrb/task/base/lifecycle.py +8 -4
  60. zrb/task/base/monitoring.py +12 -7
  61. zrb/task/base_task.py +69 -5
  62. zrb/task/base_trigger.py +12 -5
  63. zrb/task/llm/agent.py +128 -167
  64. zrb/task/llm/agent_runner.py +152 -0
  65. zrb/task/llm/config.py +39 -20
  66. zrb/task/llm/conversation_history.py +110 -29
  67. zrb/task/llm/conversation_history_model.py +4 -179
  68. zrb/task/llm/default_workflow/coding/workflow.md +41 -0
  69. zrb/task/llm/default_workflow/copywriting/workflow.md +68 -0
  70. zrb/task/llm/default_workflow/git/workflow.md +118 -0
  71. zrb/task/llm/default_workflow/golang/workflow.md +128 -0
  72. zrb/task/llm/default_workflow/html-css/workflow.md +135 -0
  73. zrb/task/llm/default_workflow/java/workflow.md +146 -0
  74. zrb/task/llm/default_workflow/javascript/workflow.md +158 -0
  75. zrb/task/llm/default_workflow/python/workflow.md +160 -0
  76. zrb/task/llm/default_workflow/researching/workflow.md +153 -0
  77. zrb/task/llm/default_workflow/rust/workflow.md +162 -0
  78. zrb/task/llm/default_workflow/shell/workflow.md +299 -0
  79. zrb/task/llm/file_replacement.py +206 -0
  80. zrb/task/llm/file_tool_model.py +57 -0
  81. zrb/task/llm/history_processor.py +206 -0
  82. zrb/task/llm/history_summarization.py +2 -193
  83. zrb/task/llm/print_node.py +184 -64
  84. zrb/task/llm/prompt.py +175 -179
  85. zrb/task/llm/subagent_conversation_history.py +41 -0
  86. zrb/task/llm/tool_wrapper.py +226 -85
  87. zrb/task/llm/workflow.py +76 -0
  88. zrb/task/llm_task.py +109 -71
  89. zrb/task/make_task.py +2 -3
  90. zrb/task/rsync_task.py +25 -10
  91. zrb/task/scheduler.py +4 -4
  92. zrb/util/attr.py +54 -39
  93. zrb/util/cli/markdown.py +12 -0
  94. zrb/util/cli/text.py +30 -0
  95. zrb/util/file.py +12 -3
  96. zrb/util/git.py +2 -2
  97. zrb/util/{llm/prompt.py → markdown.py} +2 -3
  98. zrb/util/string/conversion.py +1 -1
  99. zrb/util/truncate.py +23 -0
  100. zrb/util/yaml.py +204 -0
  101. zrb/xcom/xcom.py +10 -0
  102. {zrb-1.15.3.dist-info → zrb-1.21.29.dist-info}/METADATA +38 -18
  103. {zrb-1.15.3.dist-info → zrb-1.21.29.dist-info}/RECORD +105 -79
  104. {zrb-1.15.3.dist-info → zrb-1.21.29.dist-info}/WHEEL +1 -1
  105. zrb/task/llm/default_workflow/coding.md +0 -24
  106. zrb/task/llm/default_workflow/copywriting.md +0 -17
  107. zrb/task/llm/default_workflow/researching.md +0 -18
  108. {zrb-1.15.3.dist-info → zrb-1.21.29.dist-info}/entry_points.txt +0 -0
@@ -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 the files and directories within a specified path.
90
+ Lists files recursively up to a specified depth.
104
91
 
105
- This is a fundamental tool for exploring the file system. Use it to
106
- discover the structure of a directory, find specific files, or get a
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, optional): The directory path to list. Defaults to the
111
- current directory (".").
112
- recursive (bool, optional): If True, lists files and directories
113
- recursively. If False, lists only the top-level contents.
114
- Defaults to True.
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
- str: A JSON string containing a list of file and directory paths
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
- if recursive:
143
- for root, dirs, files in os.walk(abs_path, topdown=True):
144
- # Filter directories in-place
145
- dirs[:] = [
146
- d
147
- for d in dirs
148
- if (include_hidden or not _is_hidden(d))
149
- and not is_excluded(d, patterns_to_exclude)
150
- ]
151
- # Process files
152
- for filename in files:
153
- if (include_hidden or not _is_hidden(filename)) and not is_excluded(
154
- filename, patterns_to_exclude
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
- all_files.append(full_path)
173
- # Return paths relative to the original path requested
174
- try:
175
- rel_files = [os.path.relpath(f, abs_path) for f in all_files]
176
- return json.dumps({"files": sorted(rel_files)})
177
- except (
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
- path: str,
222
- start_line: Optional[int] = None,
223
- end_line: Optional[int] = None,
224
- ) -> str:
177
+ file: FileToRead | list[FileToRead],
178
+ ) -> dict[str, Any]:
225
179
  """
226
- Reads the content of a file, optionally from a specific start line to an
227
- end line.
228
-
229
- This tool is essential for inspecting file contents. It can read both text
230
- and PDF files. The returned content is prefixed with line numbers, which is
231
- crucial for providing context when you need to modify the file later with
232
- the `apply_diff` tool.
233
-
234
- Use this tool to:
235
- - Examine the source code of a file.
236
- - Read configuration files.
237
- - Check the contents of a document.
238
-
239
- Args:
240
- path (str): The path to the file to read.
241
- start_line (int, optional): The 1-based line number to start reading
242
- from. If omitted, reading starts from the beginning of the file.
243
- end_line (int, optional): The 1-based line number to stop reading at
244
- (inclusive). If omitted, reads to the end of the file.
245
-
246
- Returns:
247
- str: A JSON object containing the file path, the requested content
248
- with line numbers, the start and end lines, and the total number
249
- of lines in the file.
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
- abs_path = os.path.abspath(os.path.expanduser(path))
258
- # Check if file exists
259
- if not os.path.exists(abs_path):
260
- raise FileNotFoundError(f"File not found: {path}")
261
- try:
262
- content = read_file_with_line_numbers(abs_path)
263
- lines = content.splitlines()
264
- total_lines = len(lines)
265
- # Adjust line indices (convert from 1-based to 0-based)
266
- start_idx = (start_line - 1) if start_line is not None else 0
267
- end_idx = end_line if end_line is not None else total_lines
268
- # Validate indices
269
- if start_idx < 0:
270
- start_idx = 0
271
- if end_idx > total_lines:
272
- end_idx = total_lines
273
- if start_idx > end_idx:
274
- start_idx = end_idx
275
- # Select the lines for the result
276
- selected_lines = lines[start_idx:end_idx]
277
- content_result = "\n".join(selected_lines)
278
- return json.dumps(
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, # Convert back to 1-based for output
283
- "end_line": end_idx, # end_idx is already exclusive upper bound
238
+ "start_line": start_idx + 1,
239
+ "end_line": end_idx,
284
240
  "total_lines": total_lines,
285
241
  }
286
- )
287
- except (OSError, IOError) as e:
288
- raise OSError(f"Error reading file {path}: {e}")
289
- except Exception as e:
290
- raise RuntimeError(f"Unexpected error reading file {path}: {e}")
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
- path: str,
295
- content: str,
296
- ) -> str:
256
+ file: FileToWrite | list[FileToWrite],
257
+ ) -> str | dict[str, Any]:
297
258
  """
298
- Writes content to a file, completely overwriting it if it exists or
299
- creating it if it doesn't.
300
-
301
- Use this tool to create new files or to replace the entire content of
302
- existing files. This is a destructive operation, so be certain of your
303
- actions. Always read the file first to understand its contents before
304
- overwriting it, unless you are creating a new file.
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
- path (str): The path to the file to write to.
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
- str: A JSON object indicating success or failure.
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
- try:
316
- abs_path = os.path.abspath(os.path.expanduser(path))
317
- # Ensure directory exists
318
- directory = os.path.dirname(abs_path)
319
- if directory and not os.path.exists(directory):
320
- os.makedirs(directory, exist_ok=True)
321
- write_file(abs_path, content)
322
- result_data = {"success": True, "path": path}
323
- return json.dumps(result_data)
324
- except (OSError, IOError) as e:
325
- raise OSError(f"Error writing file {path}: {e}")
326
- except Exception as e:
327
- raise RuntimeError(f"Unexpected error writing file {path}: {e}")
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 regular expression (regex) pattern within files in a
338
- specified directory.
326
+ Searches for a regex pattern in files within a directory.
339
327
 
340
- This tool is invaluable for finding specific code, configuration, or text
341
- across multiple files. Use it to locate function definitions, variable
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): The directory path to start the search from.
346
- regex (str): The Python-compatible regular expression pattern to search
347
- for.
348
- file_pattern (str, optional): A glob pattern to filter which files get
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
- str: A JSON object containing a summary of the search and a list of
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 json.dumps(
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, pattern: re.Pattern, context_lines: int = 2
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
- path: str,
449
- old_string: str,
450
- new_string: str,
451
- ) -> str:
426
+ file: FileReplacement | list[FileReplacement],
427
+ ) -> str | dict[str, Any]:
452
428
  """
453
- Replaces the first occurrence of a string in a file.
454
-
455
- This tool is for making targeted modifications to a file. It is a
456
- single-step operation that is generally safer and more ergonomic than
457
- `write_to_file` for small changes.
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
- To ensure the replacement is applied correctly and to avoid ambiguity, the
460
- `old_string` parameter should be a unique, multi-line string that includes
461
- context from before and after the code you want to change.
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
- path (str): The path of the file to modify.
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
- str: A JSON object indicating the success or failure of the operation.
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
- abs_path = os.path.abspath(os.path.expanduser(path))
476
- if not os.path.exists(abs_path):
477
- raise FileNotFoundError(f"File not found: {path}")
478
- try:
479
- content = read_file(abs_path)
480
- if old_string not in content:
481
- raise ValueError(f"old_string not found in file: {path}")
482
- new_content = content.replace(old_string, new_string, 1)
483
- write_file(abs_path, new_content)
484
- return json.dumps({"success": True, "path": path})
485
- except ValueError as e:
486
- raise e
487
- except (OSError, IOError) as e:
488
- raise OSError(f"Error applying replacement to {path}: {e}")
489
- except Exception as e:
490
- raise RuntimeError(f"Unexpected error applying replacement to {path}: {e}")
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, token_limit: int | None = None
495
- ) -> str:
514
+ ctx: AnyContext, path: str, query: str, token_threshold: int | None = None
515
+ ) -> dict[str, Any]:
496
516
  """
497
- Performs a deep, goal-oriented analysis of a single file using a sub-agent.
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
- To ensure a focused and effective analysis, it is crucial to provide a
504
- clear and specific query. Vague queries will result in a vague analysis
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
- path (str): The path to the file to be analyzed.
517
- query (str): A clear and specific question or instruction about what to
518
- analyze in the file.
519
- - Good query: "What is the purpose of the 'User' class in this
520
- file?"
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
- str: A detailed, markdown-formatted analysis of the file, tailored to
529
- the specified query.
530
- Raises:
531
- FileNotFoundError: If the specified file does not exist.
530
+ Analysis results.
532
531
  """
533
- if token_limit is None:
534
- token_limit = CFG.LLM_FILE_ANALYSIS_TOKEN_LIMIT
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="analyze file with LLM capability",
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
- {"instruction": query, "file_path": abs_path, "file_content": file_content}
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
- clipped_payload = llm_rate_limitter.clip_prompt(payload, token_limit)
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)