kolega-code 0.1.0__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 (171) hide show
  1. kolega_code/__init__.py +151 -0
  2. kolega_code/agent/__init__.py +42 -0
  3. kolega_code/agent/baseagent.py +998 -0
  4. kolega_code/agent/browseragent.py +123 -0
  5. kolega_code/agent/coder.py +157 -0
  6. kolega_code/agent/common.py +41 -0
  7. kolega_code/agent/compression.py +81 -0
  8. kolega_code/agent/context.py +112 -0
  9. kolega_code/agent/conversation.py +408 -0
  10. kolega_code/agent/generalagent.py +146 -0
  11. kolega_code/agent/investigationagent.py +123 -0
  12. kolega_code/agent/planningagent.py +187 -0
  13. kolega_code/agent/prompt_provider.py +196 -0
  14. kolega_code/agent/prompt_templates/agents/browser.j2 +102 -0
  15. kolega_code/agent/prompt_templates/agents/coder_cli_mode.j2 +127 -0
  16. kolega_code/agent/prompt_templates/agents/general.j2 +68 -0
  17. kolega_code/agent/prompt_templates/agents/investigation.j2 +72 -0
  18. kolega_code/agent/prompt_templates/common/frontend_guidance.md +36 -0
  19. kolega_code/agent/prompt_templates/common/kolega_md_instructions.md +14 -0
  20. kolega_code/agent/prompt_templates/environment_variables/workspace_env_vars.md +11 -0
  21. kolega_code/agent/prompt_templates/template_guidance/expo-template.md +379 -0
  22. kolega_code/agent/prompt_templates/template_guidance/html-website-template.md +3 -0
  23. kolega_code/agent/prompt_templates/template_guidance/mern-stack-template.md +3 -0
  24. kolega_code/agent/prompt_templates/template_guidance/react-vite-shadcdn-template.md +182 -0
  25. kolega_code/agent/prompts.py +192 -0
  26. kolega_code/agent/tests/__init__.py +0 -0
  27. kolega_code/agent/tests/llm/__init__.py +0 -0
  28. kolega_code/agent/tests/llm/test_anthropic_token_counting.py +633 -0
  29. kolega_code/agent/tests/llm/test_billing_openai_cache.py +74 -0
  30. kolega_code/agent/tests/llm/test_client.py +773 -0
  31. kolega_code/agent/tests/llm/test_dashscope_mapping.py +32 -0
  32. kolega_code/agent/tests/llm/test_error_boundary.py +322 -0
  33. kolega_code/agent/tests/llm/test_exceptions.py +249 -0
  34. kolega_code/agent/tests/llm/test_instrumented_client.py +536 -0
  35. kolega_code/agent/tests/llm/test_instrumented_client_integration.py +547 -0
  36. kolega_code/agent/tests/llm/test_langfuse_normalization.py +39 -0
  37. kolega_code/agent/tests/llm/test_model_specs.py +17 -0
  38. kolega_code/agent/tests/llm/test_openai_cached_tokens.py +58 -0
  39. kolega_code/agent/tests/llm/test_openai_cached_tokens_stream.py +74 -0
  40. kolega_code/agent/tests/llm/test_openai_message_conversion.py +30 -0
  41. kolega_code/agent/tests/llm/test_openai_token_counting.py +687 -0
  42. kolega_code/agent/tests/llm/test_tool_execution_ids.py +193 -0
  43. kolega_code/agent/tests/services/__init__.py +1 -0
  44. kolega_code/agent/tests/services/test_browser.py +447 -0
  45. kolega_code/agent/tests/services/test_browser_parity.py +353 -0
  46. kolega_code/agent/tests/services/test_file_system.py +699 -0
  47. kolega_code/agent/tests/services/test_sandbox_terminal_input.py +98 -0
  48. kolega_code/agent/tests/services/test_terminal.py +154 -0
  49. kolega_code/agent/tests/services/test_terminal_command_tracking.py +385 -0
  50. kolega_code/agent/tests/services/test_terminal_state_serializer.py +262 -0
  51. kolega_code/agent/tests/test_agent_tools_inventory.py +267 -0
  52. kolega_code/agent/tests/test_base_agent.py +1942 -0
  53. kolega_code/agent/tests/test_coder_attachments.py +330 -0
  54. kolega_code/agent/tests/test_coder_prompt_extensions.py +61 -0
  55. kolega_code/agent/tests/test_commands.py +179 -0
  56. kolega_code/agent/tests/test_duplicate_tool_results.py +556 -0
  57. kolega_code/agent/tests/test_empty_message_handling.py +48 -0
  58. kolega_code/agent/tests/test_general_agent.py +242 -0
  59. kolega_code/agent/tests/test_html.py +320 -0
  60. kolega_code/agent/tests/test_parallel_tool_calls.py +291 -0
  61. kolega_code/agent/tests/test_planning_agent.py +227 -0
  62. kolega_code/agent/tests/test_prompt_provider.py +271 -0
  63. kolega_code/agent/tests/test_tool_registry.py +102 -0
  64. kolega_code/agent/tests/test_tools.py +549 -0
  65. kolega_code/agent/tests/tool_backend/__init__.py +0 -0
  66. kolega_code/agent/tests/tool_backend/test_agent_tool.py +356 -0
  67. kolega_code/agent/tests/tool_backend/test_base_tool.py +147 -0
  68. kolega_code/agent/tests/tool_backend/test_browser_tool.py +335 -0
  69. kolega_code/agent/tests/tool_backend/test_build_tool.py +93 -0
  70. kolega_code/agent/tests/tool_backend/test_create_file_tool.py +115 -0
  71. kolega_code/agent/tests/tool_backend/test_glob_tool.py +196 -0
  72. kolega_code/agent/tests/tool_backend/test_glob_tool_sandbox_parity.py +230 -0
  73. kolega_code/agent/tests/tool_backend/test_list_directory_tool.py +292 -0
  74. kolega_code/agent/tests/tool_backend/test_read_file_tool.py +173 -0
  75. kolega_code/agent/tests/tool_backend/test_replace_entire_file_tool.py +115 -0
  76. kolega_code/agent/tests/tool_backend/test_replace_lines_tool.py +141 -0
  77. kolega_code/agent/tests/tool_backend/test_search_and_replace_tool.py +174 -0
  78. kolega_code/agent/tests/tool_backend/test_search_codebase_tool.py +228 -0
  79. kolega_code/agent/tests/tool_backend/test_terminal_tool.py +482 -0
  80. kolega_code/agent/tests/tool_backend/test_think_hard_integration.py +189 -0
  81. kolega_code/agent/tests/tool_backend/test_think_hard_streaming.py +445 -0
  82. kolega_code/agent/tests/tool_backend/test_web_fetch_tool.py +194 -0
  83. kolega_code/agent/tool_backend/agent_tool.py +414 -0
  84. kolega_code/agent/tool_backend/apply_edit_tool.py +98 -0
  85. kolega_code/agent/tool_backend/apply_patch_tool.py +514 -0
  86. kolega_code/agent/tool_backend/base_tool.py +217 -0
  87. kolega_code/agent/tool_backend/browser_tool.py +271 -0
  88. kolega_code/agent/tool_backend/build_tool.py +93 -0
  89. kolega_code/agent/tool_backend/create_file_tool.py +52 -0
  90. kolega_code/agent/tool_backend/glob_tool.py +323 -0
  91. kolega_code/agent/tool_backend/list_directory_tool.py +300 -0
  92. kolega_code/agent/tool_backend/memory_tool.py +79 -0
  93. kolega_code/agent/tool_backend/read_file_tool.py +119 -0
  94. kolega_code/agent/tool_backend/replace_entire_file_tool.py +40 -0
  95. kolega_code/agent/tool_backend/replace_lines_tool.py +97 -0
  96. kolega_code/agent/tool_backend/search_and_replace_tool.py +146 -0
  97. kolega_code/agent/tool_backend/search_codebase_tool.py +377 -0
  98. kolega_code/agent/tool_backend/streaming_tool.py +47 -0
  99. kolega_code/agent/tool_backend/terminal_tool.py +643 -0
  100. kolega_code/agent/tool_backend/think_hard_tool.py +211 -0
  101. kolega_code/agent/tool_backend/web_fetch_tool.py +205 -0
  102. kolega_code/agent/tools.py +1704 -0
  103. kolega_code/agent/utils/commands.py +94 -0
  104. kolega_code/cli/__init__.py +1 -0
  105. kolega_code/cli/app.py +2756 -0
  106. kolega_code/cli/config.py +280 -0
  107. kolega_code/cli/connection.py +49 -0
  108. kolega_code/cli/file_index.py +147 -0
  109. kolega_code/cli/main.py +564 -0
  110. kolega_code/cli/mentions.py +155 -0
  111. kolega_code/cli/messages.py +89 -0
  112. kolega_code/cli/provider_registry.py +96 -0
  113. kolega_code/cli/session_store.py +207 -0
  114. kolega_code/cli/settings.py +87 -0
  115. kolega_code/cli/skills.py +409 -0
  116. kolega_code/cli/slash_commands.py +108 -0
  117. kolega_code/cli/tests/__init__.py +1 -0
  118. kolega_code/cli/tests/test_app.py +4251 -0
  119. kolega_code/cli/tests/test_cli_config.py +171 -0
  120. kolega_code/cli/tests/test_connection.py +26 -0
  121. kolega_code/cli/tests/test_file_index.py +103 -0
  122. kolega_code/cli/tests/test_main.py +455 -0
  123. kolega_code/cli/tests/test_mentions.py +108 -0
  124. kolega_code/cli/tests/test_session_store.py +67 -0
  125. kolega_code/cli/tests/test_settings.py +62 -0
  126. kolega_code/cli/tests/test_skills.py +157 -0
  127. kolega_code/cli/tests/test_slash_commands.py +88 -0
  128. kolega_code/cli/theme.py +180 -0
  129. kolega_code/config.py +154 -0
  130. kolega_code/events.py +202 -0
  131. kolega_code/llm/client.py +300 -0
  132. kolega_code/llm/exceptions.py +285 -0
  133. kolega_code/llm/instrumented_client.py +520 -0
  134. kolega_code/llm/models.py +1368 -0
  135. kolega_code/llm/providers/__init__.py +0 -0
  136. kolega_code/llm/providers/anthropic.py +387 -0
  137. kolega_code/llm/providers/base.py +71 -0
  138. kolega_code/llm/providers/google.py +157 -0
  139. kolega_code/llm/providers/models.py +37 -0
  140. kolega_code/llm/providers/openai.py +363 -0
  141. kolega_code/llm/ratelimit.py +40 -0
  142. kolega_code/llm/specs.py +67 -0
  143. kolega_code/llm/tool_execution_ids.py +18 -0
  144. kolega_code/models/__init__.py +9 -0
  145. kolega_code/models/sandbox_terminal_state.py +47 -0
  146. kolega_code/runtime.py +50 -0
  147. kolega_code/sandbox/README.md +200 -0
  148. kolega_code/sandbox/__init__.py +21 -0
  149. kolega_code/sandbox/async_filesystem.py +475 -0
  150. kolega_code/sandbox/base.py +297 -0
  151. kolega_code/sandbox/browser.py +25 -0
  152. kolega_code/sandbox/event_loop.py +43 -0
  153. kolega_code/sandbox/filesystem.py +341 -0
  154. kolega_code/sandbox/local.py +118 -0
  155. kolega_code/sandbox/serializer.py +175 -0
  156. kolega_code/sandbox/terminal.py +868 -0
  157. kolega_code/sandbox/utils.py +216 -0
  158. kolega_code/services/base.py +255 -0
  159. kolega_code/services/browser.py +444 -0
  160. kolega_code/services/file_system.py +749 -0
  161. kolega_code/services/html.py +221 -0
  162. kolega_code/services/terminal.py +903 -0
  163. kolega_code/tools/__init__.py +22 -0
  164. kolega_code/tools/core.py +33 -0
  165. kolega_code/tools/definitions.py +81 -0
  166. kolega_code/tools/registry.py +73 -0
  167. kolega_code-0.1.0.dist-info/METADATA +157 -0
  168. kolega_code-0.1.0.dist-info/RECORD +171 -0
  169. kolega_code-0.1.0.dist-info/WHEEL +4 -0
  170. kolega_code-0.1.0.dist-info/entry_points.txt +2 -0
  171. kolega_code-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,79 @@
1
+ from .base_tool import BaseTool
2
+
3
+
4
+ class MemoryTool(BaseTool):
5
+ async def read_memory(self) -> str:
6
+ """
7
+ Read the contents of the KOLEGA.md file which serves as the agent's memory.
8
+
9
+ Returns:
10
+ The contents of the KOLEGA.md file as a string
11
+
12
+ Raises:
13
+ FileNotFoundError: If the KOLEGA.md file doesn't exist
14
+ """
15
+ memory_file = "KOLEGA.md"
16
+
17
+ if not self.filesystem.exists(memory_file):
18
+ error_msg = "Memory file KOLEGA.md not found in project root"
19
+ await self.log_error(error_msg, sender=self.caller.agent_name)
20
+ raise FileNotFoundError(error_msg)
21
+
22
+ try:
23
+ memory_content = self.filesystem.read_text(memory_file)
24
+
25
+ await self.log_info("Successfully read memory file KOLEGA.md", sender=self.caller.agent_name)
26
+ return memory_content
27
+ except PermissionError:
28
+ error_msg = "Permission denied when reading memory file KOLEGA.md"
29
+ await self.log_error(error_msg, sender=self.caller.agent_name)
30
+ raise
31
+ except Exception as e:
32
+ error_msg = f"Failed to read memory file KOLEGA.md: {str(e)}"
33
+ await self.log_error(error_msg, sender=self.caller.agent_name)
34
+ raise
35
+
36
+ async def write_memory(self, memory_content: str) -> str:
37
+ """
38
+ Write a new memory to the KOLEGA.md file which serves as the agent's memory.
39
+
40
+ The memory is added as a markdown bullet point to the file.
41
+
42
+ Args:
43
+ memory_content: The memory content to add to the file
44
+
45
+ Returns:
46
+ A confirmation message indicating success
47
+
48
+ Raises:
49
+ PermissionError: If the file cannot be written to
50
+ Exception: If any other error occurs during writing
51
+ """
52
+ memory_file = "KOLEGA.md"
53
+
54
+ try:
55
+ # Create the file if it doesn't exist
56
+ if not self.filesystem.exists(memory_file):
57
+ self.filesystem.write_text(memory_file, f"# KOLEGA Memory\n\n- {memory_content}\n")
58
+ success_msg = "Created memory file KOLEGA.md and added new memory"
59
+ else:
60
+ # Read existing content and append the new memory
61
+ existing_content = self.filesystem.read_text(memory_file)
62
+
63
+ # Add the new memory as a bullet point
64
+ updated_content = f"{existing_content.rstrip()}\n- {memory_content}\n"
65
+
66
+ # Write the updated content back to the file
67
+ self.filesystem.write_text(memory_file, updated_content)
68
+ success_msg = "Successfully added new memory to KOLEGA.md"
69
+
70
+ await self.log_info(success_msg, sender=self.caller.agent_name)
71
+ return success_msg
72
+ except PermissionError:
73
+ error_msg = "Permission denied when writing to memory file KOLEGA.md"
74
+ await self.log_error(error_msg, sender=self.caller.agent_name)
75
+ raise
76
+ except Exception as e:
77
+ error_msg = f"Failed to write to memory file KOLEGA.md: {str(e)}"
78
+ await self.log_error(error_msg, sender=self.caller.agent_name)
79
+ raise
@@ -0,0 +1,119 @@
1
+ from .base_tool import BaseTool
2
+
3
+
4
+ class ReadFileTool(BaseTool):
5
+ MAX_LINES_FOR_ENTIRE_FILE = 2000
6
+ MAX_CHARS_FOR_FILE_OUTPUT = 100_000
7
+
8
+ def _format_file_content(
9
+ self,
10
+ relative_path: str,
11
+ content: str,
12
+ *,
13
+ line_range: str = "",
14
+ line_truncation_notice: str = "",
15
+ ) -> str:
16
+ original_char_count = len(content)
17
+ char_truncated = original_char_count > self.MAX_CHARS_FOR_FILE_OUTPUT
18
+ if char_truncated:
19
+ content = content[: self.MAX_CHARS_FOR_FILE_OUTPUT]
20
+
21
+ truncated = bool(line_truncation_notice) or char_truncated
22
+ suffix_parts = []
23
+ if line_range:
24
+ suffix_parts.append(line_range)
25
+ if truncated:
26
+ suffix_parts.append("(TRUNCATED)")
27
+ suffix = f" {' '.join(suffix_parts)}" if suffix_parts else ""
28
+
29
+ notices = []
30
+ if line_truncation_notice:
31
+ notices.append(line_truncation_notice)
32
+ if char_truncated:
33
+ notices.append(
34
+ f"**File truncated by size: Showing first {self.MAX_CHARS_FOR_FILE_OUTPUT:,} "
35
+ f"of {original_char_count:,} characters**"
36
+ )
37
+ if notices:
38
+ notices.append("To read specific sections, use `read_file_section` with start/end line numbers.")
39
+
40
+ notice_text = "\n\n".join(notices)
41
+ if notice_text:
42
+ notice_text += "\n\n"
43
+
44
+ return f"# {relative_path}{suffix}\n\n{notice_text}```\n{content}\n```"
45
+
46
+ async def read_entire_file(self, relative_path: str) -> str:
47
+ """
48
+ Read the contents of a file in the project.
49
+
50
+ Note: Files exceeding 2000 lines will be truncated with a warning message.
51
+ Use read_file_section to read specific portions of large files.
52
+
53
+ Args:
54
+ relative_path: Path to the file, relative to the project root
55
+
56
+ Returns:
57
+ The contents of the file as a string formatted as markdown.
58
+ If the file exceeds 2000 lines, returns a truncated version with a warning.
59
+
60
+ Raises:
61
+ FileNotFoundError: If the file doesn't exist
62
+ """
63
+ if not self.filesystem.exists(relative_path):
64
+ raise FileNotFoundError(f"File not found: {relative_path}")
65
+
66
+ file_content = self.filesystem.read_text(relative_path)
67
+ lines = file_content.splitlines(keepends=True)
68
+ total_lines = len(lines)
69
+
70
+ if total_lines > self.MAX_LINES_FOR_ENTIRE_FILE:
71
+ # Truncate the content to the maximum allowed lines
72
+ truncated_lines = lines[: self.MAX_LINES_FOR_ENTIRE_FILE]
73
+ truncated_content = "".join(truncated_lines)
74
+
75
+ return self._format_file_content(
76
+ relative_path,
77
+ truncated_content,
78
+ line_truncation_notice=(
79
+ f"**⚠️ File truncated: Showing first {self.MAX_LINES_FOR_ENTIRE_FILE} of {total_lines} lines**"
80
+ ),
81
+ )
82
+
83
+ return self._format_file_content(relative_path, file_content)
84
+
85
+ async def read_file_section(self, relative_path: str, start_line: int, end_line: int) -> str:
86
+ """
87
+ Read a specific section of a file in the project from start_line to end_line (inclusive).
88
+
89
+ Args:
90
+ relative_path: Path to the file, relative to the project root
91
+ start_line: The line number to start reading from (1-indexed)
92
+ end_line: The line number to stop reading at (1-indexed, inclusive)
93
+
94
+ Returns:
95
+ The specified section of the file as a string formatted as markdown
96
+
97
+ Raises:
98
+ FileNotFoundError: If the file doesn't exist
99
+ ValueError: If start_line or end_line are invalid
100
+ """
101
+ if not self.filesystem.exists(relative_path):
102
+ raise FileNotFoundError(f"File not found: {relative_path}")
103
+
104
+ if start_line < 1:
105
+ raise ValueError(f"Start line must be at least 1, got {start_line}")
106
+
107
+ if end_line < start_line:
108
+ raise ValueError(f"End line ({end_line}) must be greater than or equal to start line ({start_line})")
109
+
110
+ file_content = self.filesystem.read_text(relative_path)
111
+ lines = file_content.splitlines(keepends=True)
112
+
113
+ if start_line > len(lines):
114
+ raise ValueError(f"Start line {start_line} exceeds file length {len(lines)}")
115
+
116
+ # Adjust for 0-indexed list
117
+ section_content = "".join(lines[start_line - 1 : end_line])
118
+ line_range = f"(lines {start_line}-{min(end_line, len(lines))})"
119
+ return self._format_file_content(relative_path, section_content, line_range=line_range)
@@ -0,0 +1,40 @@
1
+ from .base_tool import BaseTool
2
+
3
+
4
+ class ReplaceEntireFileTool(BaseTool):
5
+ async def replace_entire_file(self, relative_path: str, content: str) -> str:
6
+ """
7
+ Replace the entire contents of a file in the project.
8
+
9
+ Args:
10
+ relative_path: Path to the file, relative to the project root
11
+ content: New content to write to the file
12
+
13
+ Returns:
14
+ The updated contents of the file as a string formatted as markdown
15
+
16
+ Raises:
17
+ FileNotFoundError: If the file doesn't exist
18
+ PermissionError: If the file cannot be written to
19
+ """
20
+ if not self.filesystem.exists(relative_path):
21
+ error_msg = f"File not found: {relative_path}"
22
+ await self.log_error(error_msg, sender=self.caller.agent_name)
23
+ raise FileNotFoundError(error_msg)
24
+
25
+ try:
26
+ blocked_msg = self._enforce_vibe_edit_policy(relative_path)
27
+ if blocked_msg:
28
+ return blocked_msg
29
+ self.filesystem.write_text(relative_path, content)
30
+ success_msg = f"Successfully replaced file: {relative_path}"
31
+ await self.log_info(success_msg, sender=self.caller.agent_name)
32
+ return f"# {relative_path} has been replaced."
33
+ except PermissionError as e:
34
+ error_msg = f"Permission denied when writing to file: {relative_path}"
35
+ await self.log_error(error_msg, sender=self.caller.agent_name)
36
+ raise
37
+ except Exception as e:
38
+ error_msg = f"Failed to write to file {relative_path}: {str(e)}"
39
+ await self.log_error(error_msg, sender=self.caller.agent_name)
40
+ raise
@@ -0,0 +1,97 @@
1
+ from .base_tool import BaseTool
2
+
3
+
4
+ class ReplaceLinesTool(BaseTool):
5
+ async def replace_lines(self, relative_path: str, start_line: int, end_line: int, new_content: str) -> str:
6
+ """
7
+ Replace a range of lines in a file with new content.
8
+
9
+ Args:
10
+ relative_path: Path to the file, relative to the project root
11
+ start_line: The starting line number (1-indexed)
12
+ end_line: The ending line number (1-indexed, inclusive)
13
+ new_content: The new content to replace the specified lines with
14
+
15
+ Returns:
16
+ The updated contents of the file as a string formatted as markdown
17
+
18
+ Raises:
19
+ FileNotFoundError: If the file doesn't exist
20
+ ValueError: If the line range is invalid
21
+ PermissionError: If the file cannot be written to
22
+ """
23
+ if not self.filesystem.exists(relative_path):
24
+ error_msg = f"File not found: {relative_path}"
25
+ await self.log_error(error_msg, sender=self.caller.agent_name)
26
+ raise FileNotFoundError(error_msg)
27
+
28
+ if start_line < 1:
29
+ error_msg = f"Invalid start_line: {start_line}. Line numbers must be 1-indexed."
30
+ await self.log_error(error_msg, sender=self.caller.agent_name)
31
+ raise ValueError(error_msg)
32
+
33
+ if end_line < start_line:
34
+ error_msg = (
35
+ f"Invalid line range: end_line ({end_line}) must be greater than or equal to start_line ({start_line})."
36
+ )
37
+ await self.log_error(error_msg, sender=self.caller.agent_name)
38
+ raise ValueError(error_msg)
39
+
40
+ try:
41
+ # Read the original file content
42
+ file_content = self.filesystem.read_text(relative_path)
43
+ lines = file_content.splitlines(keepends=True)
44
+
45
+ # Check if the line range is valid
46
+ if start_line > len(lines):
47
+ error_msg = f"Invalid start_line: {start_line}. File only has {len(lines)} lines."
48
+ await self.log_error(error_msg, sender=self.caller.agent_name)
49
+ raise ValueError(error_msg)
50
+
51
+ # Convert to 0-indexed for internal processing
52
+ start_idx = start_line - 1
53
+ end_idx = min(end_line, len(lines))
54
+
55
+ # Store the old content before replacing
56
+ old_content = "".join(lines[start_idx:end_idx])
57
+
58
+ # Handle newlines
59
+ new_content_lines = new_content.splitlines()
60
+ if not new_content_lines:
61
+ # Empty content case
62
+ formatted_new_content = "\n" if end_idx < len(lines) else ""
63
+ else:
64
+ # Non-empty content case
65
+ formatted_new_content = "\n".join(new_content_lines)
66
+ if end_idx < len(lines) or (lines and lines[-1].endswith("\n")):
67
+ formatted_new_content += "\n"
68
+
69
+ # Replace the specified lines
70
+ updated_content = "".join(lines[:start_idx]) + formatted_new_content + "".join(lines[end_idx:])
71
+
72
+ # Write the updated content back to the file (with vibe policy enforcement)
73
+ blocked_msg = self._enforce_vibe_edit_policy(relative_path)
74
+ if blocked_msg:
75
+ return blocked_msg
76
+ self.filesystem.write_text(relative_path, updated_content)
77
+
78
+ success_msg = f"Successfully replaced lines {start_line}-{end_line} in file: {relative_path}"
79
+ await self.log_info(success_msg, sender=self.caller.agent_name)
80
+
81
+ # Return the formatted message with both old and new content
82
+ return (
83
+ f"Replaced lines {start_line}-{end_line} in file {relative_path}\n\n"
84
+ f"Replaced:\n"
85
+ f"```\n{old_content}```\n"
86
+ f"with:\n"
87
+ f"```\n{formatted_new_content}```"
88
+ )
89
+
90
+ except PermissionError:
91
+ error_msg = f"Permission denied when writing to file: {relative_path}"
92
+ await self.log_error(error_msg, sender=self.caller.agent_name)
93
+ raise
94
+ except Exception as e:
95
+ error_msg = f"Failed to replace lines in file {relative_path}: {str(e)}"
96
+ await self.log_error(error_msg, sender=self.caller.agent_name)
97
+ raise
@@ -0,0 +1,146 @@
1
+ import re
2
+
3
+ from .base_tool import BaseTool
4
+
5
+
6
+ class SearchAndReplaceTool(BaseTool):
7
+ async def search_and_replace(self, relative_path: str, blocks: str) -> str:
8
+ """
9
+ Edit a file using search and replace blocks.
10
+
11
+ The blocks should be formatted as follows:
12
+ ```
13
+ <<<<<<< SEARCH
14
+ [original code to find]
15
+ =======
16
+ [new code to replace with]
17
+ >>>>>>> REPLACE
18
+ ```
19
+
20
+ Multiple search and replace blocks can be provided in sequence.
21
+ The tool will process each block in order, updating the file incrementally.
22
+ THE INDENTATION IN THE SEARCH BLOCK MUST BE IDENTICAL TO THE EXISTING FILE.
23
+
24
+ Args:
25
+ relative_path: Path to the file to edit, relative to the project root
26
+ blocks: One or more search and replace blocks formatted as shown above
27
+
28
+ Returns:
29
+ The updated contents of the file as a string formatted as markdown
30
+
31
+ Raises:
32
+ FileNotFoundError: If the file doesn't exist
33
+ ValueError: If the search block doesn't match any content in the file
34
+ ValueError: If the blocks are malformed or incorrectly formatted
35
+ PermissionError: If the file cannot be written to
36
+ """
37
+ if not self.filesystem.exists(relative_path):
38
+ error_msg = f"File not found: {relative_path}"
39
+ await self.log_error(error_msg, sender=self.caller.agent_name)
40
+ raise FileNotFoundError(error_msg)
41
+
42
+ try:
43
+ # Read the original file content
44
+ file_content = self.filesystem.read_text(relative_path)
45
+
46
+ original_content = file_content
47
+ updated_content = file_content
48
+
49
+ # Track all the replacements made for reporting
50
+ replacements_made = []
51
+
52
+ # Parse the search and replace blocks
53
+ block_pattern = re.compile(r"<<<<<<< SEARCH\n(.*?)\n=======\n(.*?)\n>>>>>>> REPLACE", re.DOTALL)
54
+
55
+ matches = list(block_pattern.finditer(blocks))
56
+
57
+ if not matches:
58
+ error_msg = (
59
+ "No valid search and replace blocks found. Blocks must follow the format:\n"
60
+ "<<<<<<< SEARCH\n[original code]\n=======\n[new code]\n>>>>>>> REPLACE"
61
+ )
62
+ await self.log_error(error_msg, sender=self.caller.agent_name)
63
+ raise ValueError(error_msg)
64
+
65
+ # Check if there are multiple search and replace blocks
66
+ if len(matches) > 1:
67
+ error_msg = "Multiple search and replace blocks provided. This tool only supports one block at a time."
68
+ await self.log_error(error_msg, sender=self.caller.agent_name)
69
+ raise ValueError(error_msg)
70
+
71
+ # Process each search and replace block
72
+ for i, match in enumerate(matches, 1):
73
+ search_text = match.group(1)
74
+ replace_text = match.group(2)
75
+
76
+ # Validate the search and replace blocks
77
+ if not search_text.strip():
78
+ error_msg = f"Empty search block in block #{i}. Search text cannot be empty."
79
+ await self.log_error(error_msg, sender=self.caller.agent_name)
80
+ raise ValueError(error_msg)
81
+
82
+ # Check if the search text exists in the current content
83
+ if search_text not in updated_content:
84
+ # Get some context around where we expect to find it for better error reporting
85
+ error_msg = f"Search block #{i} does not match any content in the file.\nSearch text: '{search_text[:100]}{'...' if len(search_text) > 100 else ''}'"
86
+ await self.log_error(error_msg, sender=self.caller.agent_name)
87
+ raise ValueError(error_msg)
88
+
89
+ # Count occurrences to check for multiple matches
90
+ occurrences = updated_content.count(search_text)
91
+ if occurrences > 1:
92
+ error_msg = f"Search block #{i} matched {occurrences} occurrences in the file."
93
+ await self.log_error(error_msg, sender=self.caller.agent_name)
94
+ raise ValueError(error_msg)
95
+
96
+ # Track replacement details
97
+ replacements_made.append(
98
+ {
99
+ "block_number": i,
100
+ "occurrences_replaced": occurrences,
101
+ "search_text_preview": search_text[:50] + ("..." if len(search_text) > 50 else ""),
102
+ "replace_text_preview": replace_text[:50] + ("..." if len(replace_text) > 50 else ""),
103
+ }
104
+ )
105
+
106
+ # Apply the replacement
107
+ updated_content = updated_content.replace(search_text, replace_text)
108
+
109
+ # Check if anything was changed
110
+ if updated_content == original_content:
111
+ await self.log_warning(
112
+ f"No changes made to {relative_path}. All replacements were identical to original text.",
113
+ sender=self.caller.agent_name,
114
+ )
115
+ return f"# {relative_path} (No changes made)\n\n```\n{original_content}\n```"
116
+
117
+ # Write the updated content back to the file (with vibe policy enforcement)
118
+ blocked_msg = self._enforce_vibe_edit_policy(relative_path)
119
+ if blocked_msg:
120
+ return blocked_msg
121
+ self.filesystem.write_text(relative_path, updated_content)
122
+
123
+ # Extract search and replace text for output
124
+ search_text = matches[0].group(1)
125
+ replace_text = matches[0].group(2)
126
+
127
+ # Return a message similar to replace_lines tool
128
+ return (
129
+ f"Search and replace in file {relative_path}\n\n"
130
+ f"Replaced:\n"
131
+ f"```\n{search_text}\n```\n"
132
+ f"with:\n"
133
+ f"```\n{replace_text}\n```"
134
+ )
135
+
136
+ except ValueError as e:
137
+ # Re-raise ValueError exceptions which are used for validation errors
138
+ raise
139
+ except PermissionError as e:
140
+ error_msg = f"Permission denied when writing to file: {relative_path}"
141
+ await self.log_error(error_msg, sender=self.caller.agent_name)
142
+ raise
143
+ except Exception as e:
144
+ error_msg = f"Failed to apply search and replace to {relative_path}: {str(e)}"
145
+ await self.log_error(error_msg, sender=self.caller.agent_name)
146
+ raise