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.
- kolega_code/__init__.py +151 -0
- kolega_code/agent/__init__.py +42 -0
- kolega_code/agent/baseagent.py +998 -0
- kolega_code/agent/browseragent.py +123 -0
- kolega_code/agent/coder.py +157 -0
- kolega_code/agent/common.py +41 -0
- kolega_code/agent/compression.py +81 -0
- kolega_code/agent/context.py +112 -0
- kolega_code/agent/conversation.py +408 -0
- kolega_code/agent/generalagent.py +146 -0
- kolega_code/agent/investigationagent.py +123 -0
- kolega_code/agent/planningagent.py +187 -0
- kolega_code/agent/prompt_provider.py +196 -0
- kolega_code/agent/prompt_templates/agents/browser.j2 +102 -0
- kolega_code/agent/prompt_templates/agents/coder_cli_mode.j2 +127 -0
- kolega_code/agent/prompt_templates/agents/general.j2 +68 -0
- kolega_code/agent/prompt_templates/agents/investigation.j2 +72 -0
- kolega_code/agent/prompt_templates/common/frontend_guidance.md +36 -0
- kolega_code/agent/prompt_templates/common/kolega_md_instructions.md +14 -0
- kolega_code/agent/prompt_templates/environment_variables/workspace_env_vars.md +11 -0
- kolega_code/agent/prompt_templates/template_guidance/expo-template.md +379 -0
- kolega_code/agent/prompt_templates/template_guidance/html-website-template.md +3 -0
- kolega_code/agent/prompt_templates/template_guidance/mern-stack-template.md +3 -0
- kolega_code/agent/prompt_templates/template_guidance/react-vite-shadcdn-template.md +182 -0
- kolega_code/agent/prompts.py +192 -0
- kolega_code/agent/tests/__init__.py +0 -0
- kolega_code/agent/tests/llm/__init__.py +0 -0
- kolega_code/agent/tests/llm/test_anthropic_token_counting.py +633 -0
- kolega_code/agent/tests/llm/test_billing_openai_cache.py +74 -0
- kolega_code/agent/tests/llm/test_client.py +773 -0
- kolega_code/agent/tests/llm/test_dashscope_mapping.py +32 -0
- kolega_code/agent/tests/llm/test_error_boundary.py +322 -0
- kolega_code/agent/tests/llm/test_exceptions.py +249 -0
- kolega_code/agent/tests/llm/test_instrumented_client.py +536 -0
- kolega_code/agent/tests/llm/test_instrumented_client_integration.py +547 -0
- kolega_code/agent/tests/llm/test_langfuse_normalization.py +39 -0
- kolega_code/agent/tests/llm/test_model_specs.py +17 -0
- kolega_code/agent/tests/llm/test_openai_cached_tokens.py +58 -0
- kolega_code/agent/tests/llm/test_openai_cached_tokens_stream.py +74 -0
- kolega_code/agent/tests/llm/test_openai_message_conversion.py +30 -0
- kolega_code/agent/tests/llm/test_openai_token_counting.py +687 -0
- kolega_code/agent/tests/llm/test_tool_execution_ids.py +193 -0
- kolega_code/agent/tests/services/__init__.py +1 -0
- kolega_code/agent/tests/services/test_browser.py +447 -0
- kolega_code/agent/tests/services/test_browser_parity.py +353 -0
- kolega_code/agent/tests/services/test_file_system.py +699 -0
- kolega_code/agent/tests/services/test_sandbox_terminal_input.py +98 -0
- kolega_code/agent/tests/services/test_terminal.py +154 -0
- kolega_code/agent/tests/services/test_terminal_command_tracking.py +385 -0
- kolega_code/agent/tests/services/test_terminal_state_serializer.py +262 -0
- kolega_code/agent/tests/test_agent_tools_inventory.py +267 -0
- kolega_code/agent/tests/test_base_agent.py +1942 -0
- kolega_code/agent/tests/test_coder_attachments.py +330 -0
- kolega_code/agent/tests/test_coder_prompt_extensions.py +61 -0
- kolega_code/agent/tests/test_commands.py +179 -0
- kolega_code/agent/tests/test_duplicate_tool_results.py +556 -0
- kolega_code/agent/tests/test_empty_message_handling.py +48 -0
- kolega_code/agent/tests/test_general_agent.py +242 -0
- kolega_code/agent/tests/test_html.py +320 -0
- kolega_code/agent/tests/test_parallel_tool_calls.py +291 -0
- kolega_code/agent/tests/test_planning_agent.py +227 -0
- kolega_code/agent/tests/test_prompt_provider.py +271 -0
- kolega_code/agent/tests/test_tool_registry.py +102 -0
- kolega_code/agent/tests/test_tools.py +549 -0
- kolega_code/agent/tests/tool_backend/__init__.py +0 -0
- kolega_code/agent/tests/tool_backend/test_agent_tool.py +356 -0
- kolega_code/agent/tests/tool_backend/test_base_tool.py +147 -0
- kolega_code/agent/tests/tool_backend/test_browser_tool.py +335 -0
- kolega_code/agent/tests/tool_backend/test_build_tool.py +93 -0
- kolega_code/agent/tests/tool_backend/test_create_file_tool.py +115 -0
- kolega_code/agent/tests/tool_backend/test_glob_tool.py +196 -0
- kolega_code/agent/tests/tool_backend/test_glob_tool_sandbox_parity.py +230 -0
- kolega_code/agent/tests/tool_backend/test_list_directory_tool.py +292 -0
- kolega_code/agent/tests/tool_backend/test_read_file_tool.py +173 -0
- kolega_code/agent/tests/tool_backend/test_replace_entire_file_tool.py +115 -0
- kolega_code/agent/tests/tool_backend/test_replace_lines_tool.py +141 -0
- kolega_code/agent/tests/tool_backend/test_search_and_replace_tool.py +174 -0
- kolega_code/agent/tests/tool_backend/test_search_codebase_tool.py +228 -0
- kolega_code/agent/tests/tool_backend/test_terminal_tool.py +482 -0
- kolega_code/agent/tests/tool_backend/test_think_hard_integration.py +189 -0
- kolega_code/agent/tests/tool_backend/test_think_hard_streaming.py +445 -0
- kolega_code/agent/tests/tool_backend/test_web_fetch_tool.py +194 -0
- kolega_code/agent/tool_backend/agent_tool.py +414 -0
- kolega_code/agent/tool_backend/apply_edit_tool.py +98 -0
- kolega_code/agent/tool_backend/apply_patch_tool.py +514 -0
- kolega_code/agent/tool_backend/base_tool.py +217 -0
- kolega_code/agent/tool_backend/browser_tool.py +271 -0
- kolega_code/agent/tool_backend/build_tool.py +93 -0
- kolega_code/agent/tool_backend/create_file_tool.py +52 -0
- kolega_code/agent/tool_backend/glob_tool.py +323 -0
- kolega_code/agent/tool_backend/list_directory_tool.py +300 -0
- kolega_code/agent/tool_backend/memory_tool.py +79 -0
- kolega_code/agent/tool_backend/read_file_tool.py +119 -0
- kolega_code/agent/tool_backend/replace_entire_file_tool.py +40 -0
- kolega_code/agent/tool_backend/replace_lines_tool.py +97 -0
- kolega_code/agent/tool_backend/search_and_replace_tool.py +146 -0
- kolega_code/agent/tool_backend/search_codebase_tool.py +377 -0
- kolega_code/agent/tool_backend/streaming_tool.py +47 -0
- kolega_code/agent/tool_backend/terminal_tool.py +643 -0
- kolega_code/agent/tool_backend/think_hard_tool.py +211 -0
- kolega_code/agent/tool_backend/web_fetch_tool.py +205 -0
- kolega_code/agent/tools.py +1704 -0
- kolega_code/agent/utils/commands.py +94 -0
- kolega_code/cli/__init__.py +1 -0
- kolega_code/cli/app.py +2756 -0
- kolega_code/cli/config.py +280 -0
- kolega_code/cli/connection.py +49 -0
- kolega_code/cli/file_index.py +147 -0
- kolega_code/cli/main.py +564 -0
- kolega_code/cli/mentions.py +155 -0
- kolega_code/cli/messages.py +89 -0
- kolega_code/cli/provider_registry.py +96 -0
- kolega_code/cli/session_store.py +207 -0
- kolega_code/cli/settings.py +87 -0
- kolega_code/cli/skills.py +409 -0
- kolega_code/cli/slash_commands.py +108 -0
- kolega_code/cli/tests/__init__.py +1 -0
- kolega_code/cli/tests/test_app.py +4251 -0
- kolega_code/cli/tests/test_cli_config.py +171 -0
- kolega_code/cli/tests/test_connection.py +26 -0
- kolega_code/cli/tests/test_file_index.py +103 -0
- kolega_code/cli/tests/test_main.py +455 -0
- kolega_code/cli/tests/test_mentions.py +108 -0
- kolega_code/cli/tests/test_session_store.py +67 -0
- kolega_code/cli/tests/test_settings.py +62 -0
- kolega_code/cli/tests/test_skills.py +157 -0
- kolega_code/cli/tests/test_slash_commands.py +88 -0
- kolega_code/cli/theme.py +180 -0
- kolega_code/config.py +154 -0
- kolega_code/events.py +202 -0
- kolega_code/llm/client.py +300 -0
- kolega_code/llm/exceptions.py +285 -0
- kolega_code/llm/instrumented_client.py +520 -0
- kolega_code/llm/models.py +1368 -0
- kolega_code/llm/providers/__init__.py +0 -0
- kolega_code/llm/providers/anthropic.py +387 -0
- kolega_code/llm/providers/base.py +71 -0
- kolega_code/llm/providers/google.py +157 -0
- kolega_code/llm/providers/models.py +37 -0
- kolega_code/llm/providers/openai.py +363 -0
- kolega_code/llm/ratelimit.py +40 -0
- kolega_code/llm/specs.py +67 -0
- kolega_code/llm/tool_execution_ids.py +18 -0
- kolega_code/models/__init__.py +9 -0
- kolega_code/models/sandbox_terminal_state.py +47 -0
- kolega_code/runtime.py +50 -0
- kolega_code/sandbox/README.md +200 -0
- kolega_code/sandbox/__init__.py +21 -0
- kolega_code/sandbox/async_filesystem.py +475 -0
- kolega_code/sandbox/base.py +297 -0
- kolega_code/sandbox/browser.py +25 -0
- kolega_code/sandbox/event_loop.py +43 -0
- kolega_code/sandbox/filesystem.py +341 -0
- kolega_code/sandbox/local.py +118 -0
- kolega_code/sandbox/serializer.py +175 -0
- kolega_code/sandbox/terminal.py +868 -0
- kolega_code/sandbox/utils.py +216 -0
- kolega_code/services/base.py +255 -0
- kolega_code/services/browser.py +444 -0
- kolega_code/services/file_system.py +749 -0
- kolega_code/services/html.py +221 -0
- kolega_code/services/terminal.py +903 -0
- kolega_code/tools/__init__.py +22 -0
- kolega_code/tools/core.py +33 -0
- kolega_code/tools/definitions.py +81 -0
- kolega_code/tools/registry.py +73 -0
- kolega_code-0.1.0.dist-info/METADATA +157 -0
- kolega_code-0.1.0.dist-info/RECORD +171 -0
- kolega_code-0.1.0.dist-info/WHEEL +4 -0
- kolega_code-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|