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.
Files changed (117) 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/http.py +7 -8
  7. zrb/builtin/llm/attachment.py +40 -0
  8. zrb/builtin/llm/chat_completion.py +287 -0
  9. zrb/builtin/llm/chat_session.py +130 -144
  10. zrb/builtin/llm/chat_session_cmd.py +288 -0
  11. zrb/builtin/llm/chat_trigger.py +78 -0
  12. zrb/builtin/llm/history.py +4 -4
  13. zrb/builtin/llm/llm_ask.py +218 -110
  14. zrb/builtin/llm/tool/api.py +74 -62
  15. zrb/builtin/llm/tool/cli.py +56 -21
  16. zrb/builtin/llm/tool/code.py +57 -47
  17. zrb/builtin/llm/tool/file.py +292 -255
  18. zrb/builtin/llm/tool/note.py +84 -0
  19. zrb/builtin/llm/tool/rag.py +25 -18
  20. zrb/builtin/llm/tool/search/__init__.py +1 -0
  21. zrb/builtin/llm/tool/search/brave.py +66 -0
  22. zrb/builtin/llm/tool/search/searxng.py +61 -0
  23. zrb/builtin/llm/tool/search/serpapi.py +61 -0
  24. zrb/builtin/llm/tool/sub_agent.py +53 -26
  25. zrb/builtin/llm/tool/web.py +94 -157
  26. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/_zrb/entity/add_entity_util.py +7 -7
  27. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/_zrb/module/add_module_util.py +5 -5
  28. zrb/builtin/project/add/fastapp/fastapp_util.py +1 -1
  29. zrb/builtin/searxng/config/settings.yml +5671 -0
  30. zrb/builtin/searxng/start.py +21 -0
  31. zrb/builtin/setup/latex/ubuntu.py +1 -0
  32. zrb/builtin/setup/ubuntu.py +1 -1
  33. zrb/builtin/shell/autocomplete/bash.py +4 -3
  34. zrb/builtin/shell/autocomplete/zsh.py +4 -3
  35. zrb/config/config.py +297 -79
  36. zrb/config/default_prompt/file_extractor_system_prompt.md +109 -9
  37. zrb/config/default_prompt/interactive_system_prompt.md +25 -28
  38. zrb/config/default_prompt/persona.md +1 -1
  39. zrb/config/default_prompt/repo_extractor_system_prompt.md +31 -31
  40. zrb/config/default_prompt/repo_summarizer_system_prompt.md +27 -8
  41. zrb/config/default_prompt/summarization_prompt.md +57 -16
  42. zrb/config/default_prompt/system_prompt.md +29 -25
  43. zrb/config/llm_config.py +129 -24
  44. zrb/config/llm_context/config.py +127 -90
  45. zrb/config/llm_context/config_parser.py +1 -7
  46. zrb/config/llm_context/workflow.py +81 -0
  47. zrb/config/llm_rate_limitter.py +100 -47
  48. zrb/context/any_shared_context.py +7 -1
  49. zrb/context/context.py +8 -2
  50. zrb/context/shared_context.py +6 -8
  51. zrb/group/any_group.py +12 -5
  52. zrb/group/group.py +67 -3
  53. zrb/input/any_input.py +5 -1
  54. zrb/input/base_input.py +18 -6
  55. zrb/input/option_input.py +13 -1
  56. zrb/input/text_input.py +7 -24
  57. zrb/runner/cli.py +21 -20
  58. zrb/runner/common_util.py +24 -19
  59. zrb/runner/web_route/task_input_api_route.py +5 -5
  60. zrb/runner/web_route/task_session_api_route.py +1 -4
  61. zrb/runner/web_util/user.py +7 -3
  62. zrb/session/any_session.py +12 -6
  63. zrb/session/session.py +39 -18
  64. zrb/task/any_task.py +24 -3
  65. zrb/task/base/context.py +17 -9
  66. zrb/task/base/execution.py +15 -8
  67. zrb/task/base/lifecycle.py +8 -4
  68. zrb/task/base/monitoring.py +12 -7
  69. zrb/task/base_task.py +69 -5
  70. zrb/task/base_trigger.py +12 -5
  71. zrb/task/llm/agent.py +130 -145
  72. zrb/task/llm/agent_runner.py +152 -0
  73. zrb/task/llm/config.py +45 -13
  74. zrb/task/llm/conversation_history.py +110 -29
  75. zrb/task/llm/conversation_history_model.py +4 -179
  76. zrb/task/llm/default_workflow/coding/workflow.md +41 -0
  77. zrb/task/llm/default_workflow/copywriting/workflow.md +68 -0
  78. zrb/task/llm/default_workflow/git/workflow.md +118 -0
  79. zrb/task/llm/default_workflow/golang/workflow.md +128 -0
  80. zrb/task/llm/default_workflow/html-css/workflow.md +135 -0
  81. zrb/task/llm/default_workflow/java/workflow.md +146 -0
  82. zrb/task/llm/default_workflow/javascript/workflow.md +158 -0
  83. zrb/task/llm/default_workflow/python/workflow.md +160 -0
  84. zrb/task/llm/default_workflow/researching/workflow.md +153 -0
  85. zrb/task/llm/default_workflow/rust/workflow.md +162 -0
  86. zrb/task/llm/default_workflow/shell/workflow.md +299 -0
  87. zrb/task/llm/file_replacement.py +206 -0
  88. zrb/task/llm/file_tool_model.py +57 -0
  89. zrb/task/llm/history_processor.py +206 -0
  90. zrb/task/llm/history_summarization.py +2 -192
  91. zrb/task/llm/print_node.py +192 -64
  92. zrb/task/llm/prompt.py +198 -153
  93. zrb/task/llm/subagent_conversation_history.py +41 -0
  94. zrb/task/llm/tool_confirmation_completer.py +41 -0
  95. zrb/task/llm/tool_wrapper.py +216 -55
  96. zrb/task/llm/workflow.py +76 -0
  97. zrb/task/llm_task.py +122 -70
  98. zrb/task/make_task.py +2 -3
  99. zrb/task/rsync_task.py +25 -10
  100. zrb/task/scheduler.py +4 -4
  101. zrb/util/attr.py +54 -39
  102. zrb/util/cli/markdown.py +12 -0
  103. zrb/util/cli/text.py +30 -0
  104. zrb/util/file.py +27 -11
  105. zrb/util/git.py +2 -2
  106. zrb/util/{llm/prompt.py → markdown.py} +2 -3
  107. zrb/util/string/conversion.py +1 -1
  108. zrb/util/truncate.py +23 -0
  109. zrb/util/yaml.py +204 -0
  110. zrb/xcom/xcom.py +10 -0
  111. {zrb-1.13.1.dist-info → zrb-1.21.33.dist-info}/METADATA +40 -20
  112. {zrb-1.13.1.dist-info → zrb-1.21.33.dist-info}/RECORD +114 -83
  113. {zrb-1.13.1.dist-info → zrb-1.21.33.dist-info}/WHEEL +1 -1
  114. zrb/task/llm/default_workflow/coding.md +0 -24
  115. zrb/task/llm/default_workflow/copywriting.md +0 -17
  116. zrb/task/llm/default_workflow/researching.md +0 -18
  117. {zrb-1.13.1.dist-info → zrb-1.21.33.dist-info}/entry_points.txt +0 -0
@@ -2,17 +2,15 @@ import fnmatch
2
2
  import json
3
3
  import os
4
4
  import re
5
- from typing import Any, Dict, List, Optional
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 the files and directories within a specified path.
90
+ Lists files recursively up to a specified depth.
93
91
 
94
- This is a fundamental tool for exploring the file system. Use it to discover the structure of a directory, find specific files, or get a general overview of the project layout before performing other operations.
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, optional): The directory path to list. Defaults to the current directory (".").
98
- recursive (bool, optional): If True, lists files and directories recursively. If False, lists only the top-level contents. Defaults to True.
99
- include_hidden (bool, optional): If True, includes hidden files and directories (those starting with a dot). Defaults to False.
100
- excluded_patterns (list[str], optional): A list of glob patterns to exclude from the listing. This is useful for ignoring irrelevant files like build artifacts or virtual environments. Defaults to a standard list of common exclusion patterns.
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
- str: A JSON string containing a list of file and directory paths relative to the input path.
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
- if recursive:
122
- for root, dirs, files in os.walk(abs_path, topdown=True):
123
- # Filter directories in-place
124
- dirs[:] = [
125
- d
126
- for d in dirs
127
- if (include_hidden or not _is_hidden(d))
128
- and not is_excluded(d, patterns_to_exclude)
129
- ]
130
- # Process files
131
- for filename in files:
132
- if (include_hidden or not _is_hidden(filename)) and not is_excluded(
133
- filename, patterns_to_exclude
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
- all_files.append(full_path)
152
- # Return paths relative to the original path requested
153
- try:
154
- rel_files = [os.path.relpath(f, abs_path) for f in all_files]
155
- return json.dumps({"files": sorted(rel_files)})
156
- except (
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
- path: str,
201
- start_line: Optional[int] = None,
202
- end_line: Optional[int] = None,
203
- ) -> str:
181
+ file: FileToRead | list[FileToRead],
182
+ ) -> dict[str, Any]:
204
183
  """
205
- Reads the content of a file, optionally from a specific start line to an end line.
206
-
207
- This tool is essential for inspecting file contents. It can read both text and PDF files. The returned content is prefixed with line numbers, which is crucial for providing context when you need to modify the file later with the `apply_diff` tool.
208
-
209
- Use this tool to:
210
- - Examine the source code of a file.
211
- - Read configuration files.
212
- - Check the contents of a document.
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
- path (str): The path to the file to read.
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
- str: A JSON object containing the file path, the requested content with line numbers, the start and end lines, and the total number of lines in the file.
221
- Example: '{"path": "src/main.py", "content": "1| import os\n2| \n3| print(\"Hello, World!\")", "start_line": 1, "end_line": 3, "total_lines": 3}'
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
- abs_path = os.path.abspath(os.path.expanduser(path))
226
- # Check if file exists
227
- if not os.path.exists(abs_path):
228
- raise FileNotFoundError(f"File not found: {path}")
229
- try:
230
- content = read_file_with_line_numbers(abs_path)
231
- lines = content.splitlines()
232
- total_lines = len(lines)
233
- # Adjust line indices (convert from 1-based to 0-based)
234
- start_idx = (start_line - 1) if start_line is not None else 0
235
- end_idx = end_line if end_line is not None else total_lines
236
- # Validate indices
237
- if start_idx < 0:
238
- start_idx = 0
239
- if end_idx > total_lines:
240
- end_idx = total_lines
241
- if start_idx > end_idx:
242
- start_idx = end_idx
243
- # Select the lines for the result
244
- selected_lines = lines[start_idx:end_idx]
245
- content_result = "\n".join(selected_lines)
246
- return json.dumps(
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, # Convert back to 1-based for output
251
- "end_line": end_idx, # end_idx is already exclusive upper bound
248
+ "start_line": start_idx + 1,
249
+ "end_line": end_idx,
252
250
  "total_lines": total_lines,
253
251
  }
254
- )
255
- except (OSError, IOError) as e:
256
- raise OSError(f"Error reading file {path}: {e}")
257
- except Exception as e:
258
- raise RuntimeError(f"Unexpected error reading file {path}: {e}")
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
- path: str,
263
- content: str,
264
- ) -> str:
266
+ file: FileToWrite | list[FileToWrite],
267
+ ) -> str | dict[str, Any]:
265
268
  """
266
- Writes content to a file, completely overwriting it if it exists or creating it if it doesn't.
267
-
268
- Use this tool to create new files or to replace the entire content of existing files. This is a destructive operation, so be certain of your actions. Always read the file first to understand its contents before overwriting it, unless you are creating a new file.
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
- path (str): The path to the file to write to.
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
- str: A JSON object indicating success or failure.
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
- try:
279
- abs_path = os.path.abspath(os.path.expanduser(path))
280
- # Ensure directory exists
281
- directory = os.path.dirname(abs_path)
282
- if directory and not os.path.exists(directory):
283
- os.makedirs(directory, exist_ok=True)
284
- write_file(abs_path, content)
285
- result_data = {"success": True, "path": path}
286
- return json.dumps(result_data)
287
- except (OSError, IOError) as e:
288
- raise OSError(f"Error writing file {path}: {e}")
289
- except Exception as e:
290
- raise RuntimeError(f"Unexpected error writing file {path}: {e}")
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 regular expression (regex) pattern within files in a specified directory.
336
+ Searches for a regex pattern in files within a directory.
301
337
 
302
- This tool is invaluable for finding specific code, configuration, or text across multiple files. Use it to locate function definitions, variable assignments, error messages, or any other text pattern.
338
+ Example:
339
+ search_files(path='src', regex='class \\w+', file_pattern='*.py', include_hidden=False)
303
340
 
304
341
  Args:
305
- path (str): The directory path to start the search from.
306
- regex (str): The Python-compatible regular expression pattern to search for.
307
- file_pattern (str, optional): A glob pattern to filter which files get searched (e.g., "*.py", "*.md"). If omitted, all files are searched.
308
- include_hidden (bool, optional): If True, the search will include hidden files and directories. Defaults to True.
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
- str: A JSON object containing a summary of the search and a list of results. Each result includes the file path and a list of matches, with each match showing the line number, line content, and a few lines of context from before and after the match.
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 json.dumps(
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, pattern: re.Pattern, context_lines: int = 2
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
- path: str,
402
- old_string: str,
403
- new_string: str,
404
- ) -> str:
436
+ file: FileReplacement | list[FileReplacement],
437
+ ) -> str | dict[str, Any]:
405
438
  """
406
- Replaces the first occurrence of a string in a file.
407
-
408
- This tool is for making targeted modifications to a file. It is a single-step operation that is generally safer and more ergonomic than `write_to_file` for small changes.
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
- To ensure the replacement is applied correctly and to avoid ambiguity, the `old_string` parameter should be a unique, multi-line string that includes context from before and after the code you want to change.
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
- path (str): The path of the file to modify.
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
- str: A JSON object indicating the success or failure of the operation.
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
- abs_path = os.path.abspath(os.path.expanduser(path))
424
- if not os.path.exists(abs_path):
425
- raise FileNotFoundError(f"File not found: {path}")
426
- try:
427
- content = read_file(abs_path)
428
- if old_string not in content:
429
- raise ValueError(f"old_string not found in file: {path}")
430
- new_content = content.replace(old_string, new_string, 1)
431
- write_file(abs_path, new_content)
432
- return json.dumps({"success": True, "path": path})
433
- except ValueError as e:
434
- raise e
435
- except (OSError, IOError) as e:
436
- raise OSError(f"Error applying replacement to {path}: {e}")
437
- except Exception as e:
438
- raise RuntimeError(f"Unexpected error applying replacement to {path}: {e}")
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, token_limit: int | None = None
443
- ) -> str:
524
+ ctx: AnyContext, path: str, query: str, token_threshold: int | None = None
525
+ ) -> dict[str, Any]:
444
526
  """
445
- Performs a deep, goal-oriented analysis of a single file using a sub-agent.
527
+ Analyzes a file using a sub-agent for complex questions.
446
528
 
447
- This tool is ideal for complex questions about a single file that go beyond simple reading or searching. It uses a specialized sub-agent to analyze the file's content in relation to a specific query.
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
- Use this tool to:
450
- - Summarize the purpose and functionality of a script or configuration file.
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
- path (str): The path to the file to be analyzed.
457
- query (str): A clear and specific question or instruction about what to analyze in the file.
458
- token_limit (int, optional): The maximum token length of the file content to be passed to the analysis sub-agent.
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
- str: A detailed, markdown-formatted analysis of the file, tailored to the specified query.
462
- Raises:
463
- FileNotFoundError: If the specified file does not exist.
546
+ Analysis results.
464
547
  """
465
- if token_limit is None:
466
- token_limit = CFG.LLM_FILE_ANALYSIS_TOKEN_LIMIT
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="analyze file with LLM capability",
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
- {"instruction": query, "file_path": abs_path, "file_content": file_content}
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
- clipped_payload = llm_rate_limitter.clip_prompt(payload, token_limit)
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)