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.

Files changed (147) hide show
  1. zrb/__init__.py +126 -113
  2. zrb/__main__.py +1 -1
  3. zrb/attr/type.py +10 -7
  4. zrb/builtin/__init__.py +2 -50
  5. zrb/builtin/git.py +12 -1
  6. zrb/builtin/group.py +31 -15
  7. zrb/builtin/http.py +7 -8
  8. zrb/builtin/llm/attachment.py +40 -0
  9. zrb/builtin/llm/chat_completion.py +274 -0
  10. zrb/builtin/llm/chat_session.py +152 -85
  11. zrb/builtin/llm/chat_session_cmd.py +288 -0
  12. zrb/builtin/llm/chat_trigger.py +79 -0
  13. zrb/builtin/llm/history.py +7 -9
  14. zrb/builtin/llm/llm_ask.py +221 -98
  15. zrb/builtin/llm/tool/api.py +74 -52
  16. zrb/builtin/llm/tool/cli.py +46 -17
  17. zrb/builtin/llm/tool/code.py +71 -90
  18. zrb/builtin/llm/tool/file.py +301 -241
  19. zrb/builtin/llm/tool/note.py +84 -0
  20. zrb/builtin/llm/tool/rag.py +38 -8
  21. zrb/builtin/llm/tool/sub_agent.py +67 -50
  22. zrb/builtin/llm/tool/web.py +146 -122
  23. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/_zrb/entity/add_entity_util.py +7 -7
  24. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/_zrb/module/add_module_util.py +5 -5
  25. zrb/builtin/project/add/fastapp/fastapp_util.py +1 -1
  26. zrb/builtin/searxng/config/settings.yml +5671 -0
  27. zrb/builtin/searxng/start.py +21 -0
  28. zrb/builtin/setup/latex/ubuntu.py +1 -0
  29. zrb/builtin/setup/ubuntu.py +1 -1
  30. zrb/builtin/shell/autocomplete/bash.py +4 -3
  31. zrb/builtin/shell/autocomplete/zsh.py +4 -3
  32. zrb/builtin/todo.py +13 -2
  33. zrb/config/config.py +614 -0
  34. zrb/config/default_prompt/file_extractor_system_prompt.md +112 -0
  35. zrb/config/default_prompt/interactive_system_prompt.md +29 -0
  36. zrb/config/default_prompt/persona.md +1 -0
  37. zrb/config/default_prompt/repo_extractor_system_prompt.md +112 -0
  38. zrb/config/default_prompt/repo_summarizer_system_prompt.md +29 -0
  39. zrb/config/default_prompt/summarization_prompt.md +57 -0
  40. zrb/config/default_prompt/system_prompt.md +38 -0
  41. zrb/config/llm_config.py +339 -0
  42. zrb/config/llm_context/config.py +166 -0
  43. zrb/config/llm_context/config_parser.py +40 -0
  44. zrb/config/llm_context/workflow.py +81 -0
  45. zrb/config/llm_rate_limitter.py +190 -0
  46. zrb/{runner → config}/web_auth_config.py +17 -22
  47. zrb/context/any_shared_context.py +17 -1
  48. zrb/context/context.py +16 -2
  49. zrb/context/shared_context.py +18 -8
  50. zrb/group/any_group.py +12 -5
  51. zrb/group/group.py +67 -3
  52. zrb/input/any_input.py +5 -1
  53. zrb/input/base_input.py +18 -6
  54. zrb/input/option_input.py +13 -1
  55. zrb/input/text_input.py +8 -25
  56. zrb/runner/cli.py +25 -23
  57. zrb/runner/common_util.py +24 -19
  58. zrb/runner/web_app.py +3 -3
  59. zrb/runner/web_route/docs_route.py +1 -1
  60. zrb/runner/web_route/error_page/serve_default_404.py +1 -1
  61. zrb/runner/web_route/error_page/show_error_page.py +1 -1
  62. zrb/runner/web_route/home_page/home_page_route.py +2 -2
  63. zrb/runner/web_route/login_api_route.py +1 -1
  64. zrb/runner/web_route/login_page/login_page_route.py +2 -2
  65. zrb/runner/web_route/logout_api_route.py +1 -1
  66. zrb/runner/web_route/logout_page/logout_page_route.py +2 -2
  67. zrb/runner/web_route/node_page/group/show_group_page.py +1 -1
  68. zrb/runner/web_route/node_page/node_page_route.py +1 -1
  69. zrb/runner/web_route/node_page/task/show_task_page.py +1 -1
  70. zrb/runner/web_route/refresh_token_api_route.py +1 -1
  71. zrb/runner/web_route/static/static_route.py +1 -1
  72. zrb/runner/web_route/task_input_api_route.py +6 -6
  73. zrb/runner/web_route/task_session_api_route.py +20 -12
  74. zrb/runner/web_util/cookie.py +1 -1
  75. zrb/runner/web_util/token.py +1 -1
  76. zrb/runner/web_util/user.py +8 -4
  77. zrb/session/any_session.py +24 -17
  78. zrb/session/session.py +50 -25
  79. zrb/session_state_logger/any_session_state_logger.py +9 -4
  80. zrb/session_state_logger/file_session_state_logger.py +16 -6
  81. zrb/session_state_logger/session_state_logger_factory.py +1 -1
  82. zrb/task/any_task.py +30 -9
  83. zrb/task/base/context.py +17 -9
  84. zrb/task/base/execution.py +15 -8
  85. zrb/task/base/lifecycle.py +8 -4
  86. zrb/task/base/monitoring.py +12 -7
  87. zrb/task/base_task.py +69 -5
  88. zrb/task/base_trigger.py +12 -5
  89. zrb/task/cmd_task.py +1 -1
  90. zrb/task/llm/agent.py +154 -161
  91. zrb/task/llm/agent_runner.py +152 -0
  92. zrb/task/llm/config.py +47 -18
  93. zrb/task/llm/conversation_history.py +209 -0
  94. zrb/task/llm/conversation_history_model.py +67 -0
  95. zrb/task/llm/default_workflow/coding/workflow.md +41 -0
  96. zrb/task/llm/default_workflow/copywriting/workflow.md +68 -0
  97. zrb/task/llm/default_workflow/git/workflow.md +118 -0
  98. zrb/task/llm/default_workflow/golang/workflow.md +128 -0
  99. zrb/task/llm/default_workflow/html-css/workflow.md +135 -0
  100. zrb/task/llm/default_workflow/java/workflow.md +146 -0
  101. zrb/task/llm/default_workflow/javascript/workflow.md +158 -0
  102. zrb/task/llm/default_workflow/python/workflow.md +160 -0
  103. zrb/task/llm/default_workflow/researching/workflow.md +153 -0
  104. zrb/task/llm/default_workflow/rust/workflow.md +162 -0
  105. zrb/task/llm/default_workflow/shell/workflow.md +299 -0
  106. zrb/task/llm/error.py +24 -10
  107. zrb/task/llm/file_replacement.py +206 -0
  108. zrb/task/llm/file_tool_model.py +57 -0
  109. zrb/task/llm/history_processor.py +206 -0
  110. zrb/task/llm/history_summarization.py +11 -166
  111. zrb/task/llm/print_node.py +193 -69
  112. zrb/task/llm/prompt.py +242 -45
  113. zrb/task/llm/subagent_conversation_history.py +41 -0
  114. zrb/task/llm/tool_wrapper.py +260 -57
  115. zrb/task/llm/workflow.py +76 -0
  116. zrb/task/llm_task.py +182 -171
  117. zrb/task/make_task.py +2 -3
  118. zrb/task/rsync_task.py +26 -11
  119. zrb/task/scheduler.py +4 -4
  120. zrb/util/attr.py +54 -39
  121. zrb/util/callable.py +23 -0
  122. zrb/util/cli/markdown.py +12 -0
  123. zrb/util/cli/text.py +30 -0
  124. zrb/util/file.py +29 -11
  125. zrb/util/git.py +8 -11
  126. zrb/util/git_diff_model.py +10 -0
  127. zrb/util/git_subtree.py +9 -14
  128. zrb/util/git_subtree_model.py +32 -0
  129. zrb/util/init_path.py +1 -1
  130. zrb/util/markdown.py +62 -0
  131. zrb/util/string/conversion.py +2 -2
  132. zrb/util/todo.py +17 -50
  133. zrb/util/todo_model.py +46 -0
  134. zrb/util/truncate.py +23 -0
  135. zrb/util/yaml.py +204 -0
  136. zrb/xcom/xcom.py +10 -0
  137. zrb-1.21.29.dist-info/METADATA +270 -0
  138. {zrb-1.8.10.dist-info → zrb-1.21.29.dist-info}/RECORD +140 -98
  139. {zrb-1.8.10.dist-info → zrb-1.21.29.dist-info}/WHEEL +1 -1
  140. zrb/config.py +0 -335
  141. zrb/llm_config.py +0 -411
  142. zrb/llm_rate_limitter.py +0 -125
  143. zrb/task/llm/context.py +0 -102
  144. zrb/task/llm/context_enrichment.py +0 -199
  145. zrb/task/llm/history.py +0 -211
  146. zrb-1.8.10.dist-info/METADATA +0 -264
  147. {zrb-1.8.10.dist-info → zrb-1.21.29.dist-info}/entry_points.txt +0 -0
@@ -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.llm_rate_limitter import llm_rate_limitter
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
- """List files/directories in a path, excluding specified patterns.
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): Path to list. Pass exactly as provided, including '~'. Defaults to ".".
112
- recursive (bool): List recursively. Defaults to True.
113
- include_hidden (bool): Include hidden files/dirs. Defaults to False.
114
- excluded_patterns (Optional[List[str]]): List of glob patterns to exclude.
115
- Defaults to a comprehensive list of common temporary/artifact 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.
101
+
116
102
  Returns:
117
- str: JSON string: {"files": ["file1.txt", ...]} or {"error": "..."}
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
- if recursive:
135
- for root, dirs, files in os.walk(abs_path, topdown=True):
136
- # Filter directories in-place
137
- dirs[:] = [
138
- d
139
- for d in dirs
140
- if (include_hidden or not _is_hidden(d))
141
- and not is_excluded(d, patterns_to_exclude)
142
- ]
143
- # Process files
144
- for filename in files:
145
- if (include_hidden or not _is_hidden(filename)) and not is_excluded(
146
- filename, patterns_to_exclude
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
- all_files.append(full_path)
165
- # Return paths relative to the original path requested
166
- try:
167
- rel_files = [os.path.relpath(f, abs_path) for f in all_files]
168
- return json.dumps({"files": sorted(rel_files)})
169
- except (
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
- path: str,
214
- start_line: Optional[int] = None,
215
- end_line: Optional[int] = None,
216
- ) -> str:
217
- """Read file content (or specific lines) at a path, including line numbers.
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
- path (str): Path to read. Pass exactly as provided, including '~'.
220
- start_line (Optional[int]): Starting line number (1-based).
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
- str: JSON: {"path": "...", "content": "...", "start_line": N, ...} or {"error": "..."}
226
- The content includes line numbers.
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
- abs_path = os.path.abspath(os.path.expanduser(path))
231
- # Check if file exists
232
- if not os.path.exists(abs_path):
233
- raise FileNotFoundError(f"File not found: {path}")
234
- try:
235
- content = read_file_with_line_numbers(abs_path)
236
- lines = content.splitlines()
237
- total_lines = len(lines)
238
- # Adjust line indices (convert from 1-based to 0-based)
239
- start_idx = (start_line - 1) if start_line is not None else 0
240
- end_idx = end_line if end_line is not None else total_lines
241
- # Validate indices
242
- if start_idx < 0:
243
- start_idx = 0
244
- if end_idx > total_lines:
245
- end_idx = total_lines
246
- if start_idx > end_idx:
247
- start_idx = end_idx
248
- # Select the lines for the result
249
- selected_lines = lines[start_idx:end_idx]
250
- content_result = "\n".join(selected_lines)
251
- return json.dumps(
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, # Convert back to 1-based for output
256
- "end_line": end_idx, # end_idx is already exclusive upper bound
238
+ "start_line": start_idx + 1,
239
+ "end_line": end_idx,
257
240
  "total_lines": total_lines,
258
241
  }
259
- )
260
- except (OSError, IOError) as e:
261
- raise OSError(f"Error reading file {path}: {e}")
262
- except Exception as e:
263
- 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"]]
264
253
 
265
254
 
266
255
  def write_to_file(
267
- path: str,
268
- content: str,
269
- line_count: int,
270
- ) -> str:
271
- """Write full content to a file. Creates/overwrites file.
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
- path (str): Path to write. Pass exactly as provided, including '~'.
274
- content (str): Full file content.
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
- str: JSON: {"success": true, "path": "f.txt", "warning": "..."} or {"error": "..."}
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
- actual_lines = len(content.splitlines())
283
- warning = None
284
- if actual_lines != line_count:
285
- warning = (
286
- f"Provided line_count ({line_count}) does not match actual "
287
- f"content lines ({actual_lines}) for file {path}"
288
- )
289
- try:
290
- abs_path = os.path.abspath(os.path.expanduser(path))
291
- # Ensure directory exists
292
- directory = os.path.dirname(abs_path)
293
- if directory and not os.path.exists(directory):
294
- os.makedirs(directory, exist_ok=True)
295
- write_file(abs_path, content)
296
- result_data = {"success": True, "path": path}
297
- if warning:
298
- result_data["warning"] = warning
299
- return json.dumps(result_data)
300
- except (OSError, IOError) as e:
301
- raise OSError(f"Error writing file {path}: {e}")
302
- except Exception as e:
303
- 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')}'"
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
- """Search files in a directory using regex, showing context.
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): Path to search. Pass exactly as provided, including '~'.
315
- regex (str): Python regex pattern to search for.
316
- file_pattern (Optional[str]): Glob pattern to filter files
317
- (e.g., '*.py'). Defaults to None.
318
- include_hidden (bool): Include hidden files/dirs. 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.
336
+
319
337
  Returns:
320
- str: JSON: {"summary": "...", "results": [{"file":"f.py", ...}]} or {"error": "..."}
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 json.dumps(
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, pattern: re.Pattern, context_lines: int = 2
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 apply_diff(
410
- path: str,
411
- start_line: int,
412
- end_line: int,
413
- search_content: str,
414
- replace_content: str,
415
- ) -> str:
416
- """Apply a precise search/replace to a file based on line numbers and content.
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
- path (str): Path to modify. Pass exactly as provided, including '~'.
419
- start_line (int): The 1-based starting line number of the content to replace.
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
- str: JSON: {"success": true, "path": "f.py"} or {"success": false, "error": "..."}
428
- Raises:
429
- Exception: If an error occurs.
466
+ Success message or error dict.
430
467
  """
431
- abs_path = os.path.abspath(os.path.expanduser(path))
432
- if not os.path.exists(abs_path):
433
- raise FileNotFoundError(f"File not found: {path}")
434
- try:
435
- content = read_file(abs_path)
436
- lines = content.splitlines()
437
- if start_line < 1 or end_line > len(lines) or start_line > end_line:
438
- raise ValueError(
439
- f"Invalid line range {start_line}-{end_line} for file with {len(lines)} lines"
440
- )
441
- original_content = "\n".join(lines[start_line - 1 : end_line])
442
- if original_content != search_content:
443
- error_message = (
444
- f"Search content does not match file content at "
445
- f"lines {start_line}-{end_line}.\n"
446
- f"Expected ({len(search_content.splitlines())} lines):\n"
447
- f"---\n{search_content}\n---\n"
448
- f"Actual ({len(lines[start_line-1:end_line])} lines):\n"
449
- f"---\n{original_content}\n---"
450
- )
451
- return json.dumps({"success": False, "path": path, "error": error_message})
452
- new_lines = (
453
- lines[: start_line - 1] + replace_content.splitlines() + lines[end_line:]
454
- )
455
- new_content = "\n".join(new_lines)
456
- if content.endswith("\n"):
457
- new_content += "\n"
458
- write_file(abs_path, new_content)
459
- return json.dumps({"success": True, "path": path})
460
- except ValueError as e:
461
- raise ValueError(f"Error parsing diff: {e}")
462
- except (OSError, IOError) as e:
463
- raise OSError(f"Error applying diff to {path}: {e}")
464
- except Exception as e:
465
- raise RuntimeError(f"Unexpected error applying diff 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}"
466
511
 
467
512
 
468
513
  async def analyze_file(
469
- ctx: AnyContext, path: str, query: str, token_limit: int = 30000
470
- ) -> str:
471
- """Analyze file using LLM capability to reduce context usage.
472
- Use this tool for:
473
- - summarization
474
- - outline/structure extraction
475
- - code review
476
- - other tasks
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
- path (str): File path to be analyze. Pass exactly as provided, including '~'.
479
- query(str): Instruction to analyze the file
480
- token_limit(Optional[int]): Max token length to be taken from file
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
- str: The analysis result
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="analyze file with LLM capability",
493
- system_prompt=_EXTRACT_INFO_FROM_FILE_SYSTEM_PROMPT,
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
- {"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
+ }
498
559
  )
499
- clipped_payload = llm_rate_limitter.clip_prompt(payload, token_limit)
500
- return await _analyze_file(ctx, clipped_payload)
560
+ return await _analyze_file(ctx, payload)