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,217 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Union, Optional, Set
|
|
4
|
+
|
|
5
|
+
from ..common import LogMixin
|
|
6
|
+
from kolega_code.config import AgentConfig
|
|
7
|
+
from kolega_code.events import AgentConnectionManager
|
|
8
|
+
from kolega_code.services.file_system import FileSystem, LocalFileSystem
|
|
9
|
+
from kolega_code.services.base import TerminalManager, BrowserManager
|
|
10
|
+
from ..prompt_provider import AgentMode
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BaseTool(LogMixin):
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
project_path: Union[str, Path],
|
|
18
|
+
workspace_id: str,
|
|
19
|
+
thread_id: str,
|
|
20
|
+
connection_manager: AgentConnectionManager,
|
|
21
|
+
config: AgentConfig,
|
|
22
|
+
caller,
|
|
23
|
+
filesystem: Optional[FileSystem] = None,
|
|
24
|
+
terminal_manager: Optional[TerminalManager] = None,
|
|
25
|
+
browser_manager: Optional[BrowserManager] = None,
|
|
26
|
+
) -> None:
|
|
27
|
+
self.workspace_id = workspace_id
|
|
28
|
+
self.thread_id = thread_id
|
|
29
|
+
self.project_path = Path(project_path) if isinstance(project_path, str) else project_path
|
|
30
|
+
self.connection_manager = connection_manager
|
|
31
|
+
self.config = config
|
|
32
|
+
self.caller = caller
|
|
33
|
+
|
|
34
|
+
# Create filesystem instance if not provided
|
|
35
|
+
if filesystem is None:
|
|
36
|
+
self.filesystem = LocalFileSystem(root_path=self.project_path)
|
|
37
|
+
else:
|
|
38
|
+
self.filesystem = filesystem
|
|
39
|
+
|
|
40
|
+
# Store optional managers (individual tools will use them if needed)
|
|
41
|
+
self.terminal_manager = terminal_manager
|
|
42
|
+
self.browser_manager = browser_manager
|
|
43
|
+
|
|
44
|
+
def _is_binary_file(self, file_path: Path) -> bool:
|
|
45
|
+
"""
|
|
46
|
+
Determine if a file is binary.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
file_path: Path to the file to check
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
True if the file is binary, False otherwise
|
|
53
|
+
"""
|
|
54
|
+
# Check file extension for common binary formats
|
|
55
|
+
binary_extensions = {
|
|
56
|
+
".pyc",
|
|
57
|
+
".so",
|
|
58
|
+
".dll",
|
|
59
|
+
".exe",
|
|
60
|
+
".bin",
|
|
61
|
+
".jar",
|
|
62
|
+
".war",
|
|
63
|
+
".jpg",
|
|
64
|
+
".jpeg",
|
|
65
|
+
".png",
|
|
66
|
+
".gif",
|
|
67
|
+
".bmp",
|
|
68
|
+
".ico",
|
|
69
|
+
".svg",
|
|
70
|
+
".pdf",
|
|
71
|
+
".zip",
|
|
72
|
+
".tar",
|
|
73
|
+
".gz",
|
|
74
|
+
".tgz",
|
|
75
|
+
".rar",
|
|
76
|
+
".7z",
|
|
77
|
+
".mp3",
|
|
78
|
+
".mp4",
|
|
79
|
+
".avi",
|
|
80
|
+
".mov",
|
|
81
|
+
".mkv",
|
|
82
|
+
".wav",
|
|
83
|
+
".o",
|
|
84
|
+
".obj",
|
|
85
|
+
".class",
|
|
86
|
+
".binary",
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if file_path.suffix.lower() in binary_extensions:
|
|
90
|
+
return True
|
|
91
|
+
|
|
92
|
+
# Check file contents (sample the first 1024 bytes)
|
|
93
|
+
try:
|
|
94
|
+
with file_path.open("rb") as f:
|
|
95
|
+
sample = f.read(1024)
|
|
96
|
+
if b"\x00" in sample: # If null byte is present, likely binary
|
|
97
|
+
return True
|
|
98
|
+
except Exception:
|
|
99
|
+
# If there's an error reading the file, consider it binary to be safe
|
|
100
|
+
return True
|
|
101
|
+
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
def _should_exclude_file(self, file_path: Path) -> bool:
|
|
105
|
+
"""
|
|
106
|
+
Determine if a file should be excluded from search.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
file_path: Path to the file to check
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
True if the file should be excluded, False otherwise
|
|
113
|
+
"""
|
|
114
|
+
# Common directories to exclude
|
|
115
|
+
exclude_directories = {
|
|
116
|
+
".git",
|
|
117
|
+
".svn",
|
|
118
|
+
".hg",
|
|
119
|
+
".idea",
|
|
120
|
+
".vscode",
|
|
121
|
+
"__pycache__",
|
|
122
|
+
"node_modules",
|
|
123
|
+
"venv",
|
|
124
|
+
"env",
|
|
125
|
+
".env",
|
|
126
|
+
"dist",
|
|
127
|
+
"build",
|
|
128
|
+
"target",
|
|
129
|
+
"bin",
|
|
130
|
+
"obj",
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
# Check if any parent directory is in the exclude list
|
|
134
|
+
for parent in file_path.parents:
|
|
135
|
+
if parent.name in exclude_directories:
|
|
136
|
+
return True
|
|
137
|
+
|
|
138
|
+
# Exclude very large files (> 10MB)
|
|
139
|
+
try:
|
|
140
|
+
if file_path.stat().st_size > 10 * 1024 * 1024: # 10MB
|
|
141
|
+
return True
|
|
142
|
+
except Exception:
|
|
143
|
+
# If we can't get the file size, exclude it to be safe
|
|
144
|
+
return True
|
|
145
|
+
|
|
146
|
+
# Check if file is excluded by .gitignore
|
|
147
|
+
if self._is_gitignored(file_path):
|
|
148
|
+
return True
|
|
149
|
+
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
def _is_gitignored(self, file_path: Path) -> bool:
|
|
153
|
+
"""
|
|
154
|
+
Check if a file is excluded by .gitignore patterns.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
file_path: Path to the file to check
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
True if the file is ignored according to .gitignore, False otherwise
|
|
161
|
+
"""
|
|
162
|
+
# Use cached gitignore patterns if available
|
|
163
|
+
if not hasattr(self, "_gitignore_spec"):
|
|
164
|
+
self._load_gitignore_patterns()
|
|
165
|
+
|
|
166
|
+
if not hasattr(self, "_gitignore_spec") or self._gitignore_spec is None:
|
|
167
|
+
return False
|
|
168
|
+
|
|
169
|
+
# Get the path relative to the project root
|
|
170
|
+
relative_path = str(file_path.relative_to(self.project_path))
|
|
171
|
+
|
|
172
|
+
# Check if the path matches any gitignore pattern
|
|
173
|
+
return self._gitignore_spec.match_file(relative_path)
|
|
174
|
+
|
|
175
|
+
def _load_gitignore_patterns(self) -> None:
|
|
176
|
+
"""
|
|
177
|
+
Load .gitignore patterns from the project root.
|
|
178
|
+
Creates a pathspec matcher that can be used to check if files match gitignore patterns.
|
|
179
|
+
"""
|
|
180
|
+
try:
|
|
181
|
+
import pathspec
|
|
182
|
+
|
|
183
|
+
if not self.filesystem.exists(".gitignore"):
|
|
184
|
+
self._gitignore_spec = None
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
gitignore_content = self.filesystem.read_text(".gitignore", encoding="utf-8")
|
|
188
|
+
|
|
189
|
+
# Parse gitignore patterns
|
|
190
|
+
self._gitignore_spec = pathspec.PathSpec.from_lines(
|
|
191
|
+
pathspec.patterns.GitWildMatchPattern, gitignore_content.splitlines()
|
|
192
|
+
)
|
|
193
|
+
except Exception as e:
|
|
194
|
+
# If there's an error loading gitignore, log it and continue without gitignore filtering
|
|
195
|
+
print(f"Error loading .gitignore: {str(e)}")
|
|
196
|
+
self._gitignore_spec = None
|
|
197
|
+
|
|
198
|
+
# --- Vibe-mode edit policy helpers ---
|
|
199
|
+
def _is_vibe_mode(self) -> bool:
|
|
200
|
+
"""Return True if caller agent is in VIBE mode (same check pattern as agents)."""
|
|
201
|
+
return getattr(self.caller, "agent_mode", None) == AgentMode.VIBE.value
|
|
202
|
+
|
|
203
|
+
def _get_vibe_blacklist_basenames(self) -> Set[str]:
|
|
204
|
+
"""Basename blacklist for files that should not be edited in vibe mode."""
|
|
205
|
+
protected_files = getattr(self.caller, "protected_files", None) if self.caller else None
|
|
206
|
+
if protected_files:
|
|
207
|
+
return set(protected_files)
|
|
208
|
+
return {"package.json", "tsconfig.json"}
|
|
209
|
+
|
|
210
|
+
def _enforce_vibe_edit_policy(self, relative_path: str) -> Optional[str]:
|
|
211
|
+
"""Return message if blocked; None if allowed."""
|
|
212
|
+
if not self._is_vibe_mode():
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
if os.path.basename(relative_path) in self._get_vibe_blacklist_basenames():
|
|
216
|
+
return f"You are not allowed to edit this file: {relative_path}"
|
|
217
|
+
return None
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Union
|
|
3
|
+
|
|
4
|
+
from kolega_code.config import AgentConfig
|
|
5
|
+
from kolega_code.events import AgentEvent
|
|
6
|
+
from kolega_code.services.browser import PlaywrightBrowserManager
|
|
7
|
+
from .base_tool import BaseTool
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BrowserTool(BaseTool):
|
|
11
|
+
MAX_HTML_CHARS_FOR_CONTENT_OUTPUT = 100_000
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
project_path: Union[str, Path],
|
|
16
|
+
workspace_id: str,
|
|
17
|
+
thread_id: str,
|
|
18
|
+
connection_manager,
|
|
19
|
+
config: AgentConfig,
|
|
20
|
+
caller,
|
|
21
|
+
filesystem=None,
|
|
22
|
+
browser_manager=None,
|
|
23
|
+
):
|
|
24
|
+
super().__init__(
|
|
25
|
+
project_path,
|
|
26
|
+
workspace_id,
|
|
27
|
+
thread_id,
|
|
28
|
+
connection_manager,
|
|
29
|
+
config,
|
|
30
|
+
caller,
|
|
31
|
+
filesystem,
|
|
32
|
+
browser_manager=browser_manager,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# Use injected browser_manager if provided, otherwise create local one
|
|
36
|
+
if self.browser_manager is None:
|
|
37
|
+
self.browser_manager = PlaywrightBrowserManager()
|
|
38
|
+
|
|
39
|
+
async def launch_browser(self, url: str) -> str:
|
|
40
|
+
browser_result = await self.browser_manager.launch_browser(url)
|
|
41
|
+
|
|
42
|
+
# Check if we got an error dict instead of a browser ID
|
|
43
|
+
if isinstance(browser_result, dict) and "error" in browser_result:
|
|
44
|
+
return f"Failed to launch browser. Error: {browser_result['error']}"
|
|
45
|
+
|
|
46
|
+
if browser_result:
|
|
47
|
+
browser_launched_event = AgentEvent(
|
|
48
|
+
event_type="browser_launched", sender=self.caller.agent_name, content={"browser_id": browser_result}
|
|
49
|
+
)
|
|
50
|
+
await self.connection_manager.broadcast_event(browser_launched_event, self.workspace_id, self.thread_id)
|
|
51
|
+
|
|
52
|
+
return f"Launched new browser with browser_id {browser_result}"
|
|
53
|
+
else:
|
|
54
|
+
return f"Failed to launch browser."
|
|
55
|
+
|
|
56
|
+
async def list_browsers(self):
|
|
57
|
+
results = await self.browser_manager.list_browsers()
|
|
58
|
+
|
|
59
|
+
if not results:
|
|
60
|
+
return "No browsers are currently running."
|
|
61
|
+
|
|
62
|
+
markdown_output = "# Running Browsers\n\n"
|
|
63
|
+
markdown_output += "| Browser ID | URL | Launched At |\n"
|
|
64
|
+
markdown_output += "|------------|-----|------------|\n"
|
|
65
|
+
|
|
66
|
+
for browser_id, browser_info in results.items():
|
|
67
|
+
url = browser_info.get("url", "N/A")
|
|
68
|
+
launched_at = browser_info.get("launched_at", "N/A")
|
|
69
|
+
markdown_output += f"| {browser_id} | {url} | {launched_at} |\n"
|
|
70
|
+
|
|
71
|
+
return markdown_output
|
|
72
|
+
|
|
73
|
+
async def get_browser_console_logs(
|
|
74
|
+
self,
|
|
75
|
+
browser_id: str,
|
|
76
|
+
max_logs: int = 50,
|
|
77
|
+
log_types: list = None,
|
|
78
|
+
minutes_back: int = None,
|
|
79
|
+
max_chars: int = 8000,
|
|
80
|
+
) -> str:
|
|
81
|
+
logs = await self.browser_manager.get_browser_console_logs(
|
|
82
|
+
browser_id, max_logs=max_logs, log_types=log_types, minutes_back=minutes_back, max_chars=max_chars
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
if not logs["console_logs"]:
|
|
86
|
+
return "## Console Logs\n\nNo console logs found."
|
|
87
|
+
|
|
88
|
+
# Add metadata about filtering
|
|
89
|
+
markdown_output = "## Console Logs\n\n"
|
|
90
|
+
markdown_output += f"**Showing {logs['returned_count']} of {logs['total_logs_count']} total logs**\n\n"
|
|
91
|
+
|
|
92
|
+
filters_applied = logs["filters_applied"]
|
|
93
|
+
if filters_applied["log_types"]:
|
|
94
|
+
markdown_output += f"**Filtered by types:** {', '.join(filters_applied['log_types'])}\n"
|
|
95
|
+
if filters_applied["minutes_back"]:
|
|
96
|
+
markdown_output += f"**Time window:** Last {filters_applied['minutes_back']} minutes\n"
|
|
97
|
+
if filters_applied["max_chars"]:
|
|
98
|
+
markdown_output += f"**Character limit:** {filters_applied['max_chars']}\n"
|
|
99
|
+
markdown_output += f"**Max logs:** {filters_applied['max_logs']}\n\n"
|
|
100
|
+
|
|
101
|
+
markdown_output += "| Type | Timestamp | Message | Location |\n"
|
|
102
|
+
markdown_output += "|------|-----------|---------|----------|\n"
|
|
103
|
+
|
|
104
|
+
for log in logs["console_logs"]:
|
|
105
|
+
log_type = log.get("type", "unknown")
|
|
106
|
+
timestamp = log.get("timestamp", "N/A")
|
|
107
|
+
text = log.get("text", "").replace("|", "\\|") # Escape pipe characters for markdown tables
|
|
108
|
+
location = log.get("location", "N/A")
|
|
109
|
+
if location and location != "N/A":
|
|
110
|
+
location_str = f"{location.get('url', 'unknown')}:{location.get('lineNumber', '?')}:{location.get('columnNumber', '?')}"
|
|
111
|
+
else:
|
|
112
|
+
location_str = "N/A"
|
|
113
|
+
markdown_output += f"| {log_type} | {timestamp} | {text} | {location_str} |\n"
|
|
114
|
+
|
|
115
|
+
return markdown_output
|
|
116
|
+
|
|
117
|
+
async def get_browser_interactive_elements(self, browser_id: str) -> str:
|
|
118
|
+
result = await self.browser_manager.get_browser_interactive_elements(browser_id)
|
|
119
|
+
|
|
120
|
+
# Format the interactive elements as markdown
|
|
121
|
+
markdown_output = f"# Interactive Elements: {result['title']}\n\n"
|
|
122
|
+
markdown_output += f"**Current URL:** {result['current_url']}\n\n"
|
|
123
|
+
|
|
124
|
+
if result["interactive_elements"]:
|
|
125
|
+
markdown_output += "## Elements\n\n"
|
|
126
|
+
markdown_output += "| Type | Text | Selector | Attributes |\n"
|
|
127
|
+
markdown_output += "|------|------|----------|------------|\n"
|
|
128
|
+
|
|
129
|
+
for element in result["interactive_elements"]:
|
|
130
|
+
element_type = element.get("element_type", "unknown")
|
|
131
|
+
text = element.get("text", "").replace("|", "\\|") # Escape pipe characters for markdown tables
|
|
132
|
+
selector = element.get("selector", "").replace("|", "\\|")
|
|
133
|
+
attributes = str(element.get("attributes", {})).replace("|", "\\|")
|
|
134
|
+
|
|
135
|
+
markdown_output += f"| {element_type} | {text} | `{selector}` | {attributes} |\n"
|
|
136
|
+
else:
|
|
137
|
+
markdown_output += "No interactive elements found on the page."
|
|
138
|
+
|
|
139
|
+
return markdown_output
|
|
140
|
+
|
|
141
|
+
async def get_browser_content(
|
|
142
|
+
self,
|
|
143
|
+
browser_id: str,
|
|
144
|
+
max_logs: int = 50,
|
|
145
|
+
log_types: list = None,
|
|
146
|
+
minutes_back: int = None,
|
|
147
|
+
max_chars: int = 8000,
|
|
148
|
+
) -> str:
|
|
149
|
+
content = await self.browser_manager.get_browser_content(
|
|
150
|
+
browser_id, max_logs=max_logs, log_types=log_types, minutes_back=minutes_back, max_chars=max_chars
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# Format the browser content as markdown
|
|
154
|
+
markdown_output = f"# Browser Content: {content['title']}\n\n"
|
|
155
|
+
markdown_output += f"**Current URL:** {content['current_url']}\n\n"
|
|
156
|
+
|
|
157
|
+
# Add console logs section if there are any
|
|
158
|
+
if content["console_logs"]:
|
|
159
|
+
markdown_output += "## Console Logs\n\n"
|
|
160
|
+
|
|
161
|
+
# Add metadata about console log filtering
|
|
162
|
+
if "console_log_metadata" in content:
|
|
163
|
+
metadata = content["console_log_metadata"]
|
|
164
|
+
markdown_output += (
|
|
165
|
+
f"**Showing {metadata['returned_count']} of {metadata['total_logs_count']} total logs**\n\n"
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
filters_applied = metadata["filters_applied"]
|
|
169
|
+
if filters_applied["log_types"]:
|
|
170
|
+
markdown_output += f"**Filtered by types:** {', '.join(filters_applied['log_types'])}\n"
|
|
171
|
+
if filters_applied["minutes_back"]:
|
|
172
|
+
markdown_output += f"**Time window:** Last {filters_applied['minutes_back']} minutes\n"
|
|
173
|
+
if filters_applied["max_chars"]:
|
|
174
|
+
markdown_output += f"**Character limit:** {filters_applied['max_chars']}\n"
|
|
175
|
+
markdown_output += f"**Max logs:** {filters_applied['max_logs']}\n\n"
|
|
176
|
+
|
|
177
|
+
markdown_output += "| Type | Timestamp | Message | Location |\n"
|
|
178
|
+
markdown_output += "|------|-----------|---------|----------|\n"
|
|
179
|
+
|
|
180
|
+
for log in content["console_logs"]:
|
|
181
|
+
log_type = log.get("type", "unknown")
|
|
182
|
+
timestamp = log.get("timestamp", "N/A")
|
|
183
|
+
text = log.get("text", "").replace("|", "\\|") # Escape pipe characters for markdown tables
|
|
184
|
+
location = log.get("location", "N/A")
|
|
185
|
+
if location and location != "N/A":
|
|
186
|
+
location_str = f"{location.get('url', 'unknown')}:{location.get('lineNumber', '?')}:{location.get('columnNumber', '?')}"
|
|
187
|
+
else:
|
|
188
|
+
location_str = "N/A"
|
|
189
|
+
markdown_output += f"| {log_type} | {timestamp} | {text} | {location_str} |\n"
|
|
190
|
+
|
|
191
|
+
# Add HTML content in a code block
|
|
192
|
+
html = content["html"]
|
|
193
|
+
original_html_chars = len(html)
|
|
194
|
+
html_truncated = original_html_chars > self.MAX_HTML_CHARS_FOR_CONTENT_OUTPUT
|
|
195
|
+
if html_truncated:
|
|
196
|
+
html = html[: self.MAX_HTML_CHARS_FOR_CONTENT_OUTPUT]
|
|
197
|
+
|
|
198
|
+
markdown_output += "\n## Page HTML\n\n"
|
|
199
|
+
if html_truncated:
|
|
200
|
+
markdown_output += (
|
|
201
|
+
f"**HTML truncated by size: Showing first {self.MAX_HTML_CHARS_FOR_CONTENT_OUTPUT:,} "
|
|
202
|
+
f"of {original_html_chars:,} characters.**\n\n"
|
|
203
|
+
)
|
|
204
|
+
markdown_output += "```html\n"
|
|
205
|
+
markdown_output += html
|
|
206
|
+
markdown_output += "\n```\n"
|
|
207
|
+
|
|
208
|
+
return markdown_output
|
|
209
|
+
|
|
210
|
+
async def take_browser_screenshot(self, browser_id: str) -> dict:
|
|
211
|
+
return await self.browser_manager.take_browser_screenshot(browser_id)
|
|
212
|
+
|
|
213
|
+
async def interact_with_browser(
|
|
214
|
+
self, browser_id: str, action: str, selector: str, text: str, scroll_px: int
|
|
215
|
+
) -> str:
|
|
216
|
+
result = await self.browser_manager.interact_with_browser(browser_id, action, selector, text, scroll_px)
|
|
217
|
+
|
|
218
|
+
# Format the interaction result as markdown
|
|
219
|
+
markdown_output = f"# Browser Interaction Result\n\n"
|
|
220
|
+
markdown_output += f"**Status:** {result['status']}\n"
|
|
221
|
+
markdown_output += f"**Current URL:** {result['current_url']}\n\n"
|
|
222
|
+
markdown_output += f"**Action Performed:** {result['action']}\n"
|
|
223
|
+
|
|
224
|
+
if result["selector"]:
|
|
225
|
+
markdown_output += f"**Selector:** `{result['selector']}`\n"
|
|
226
|
+
|
|
227
|
+
if result["text"]:
|
|
228
|
+
markdown_output += f"**Text/URL:** {result['text']}\n"
|
|
229
|
+
|
|
230
|
+
return markdown_output
|
|
231
|
+
|
|
232
|
+
async def set_browser_select_value(self, browser_id: str, selector: str, value: str) -> str:
|
|
233
|
+
result = await self.browser_manager.set_select_value(browser_id, selector, value)
|
|
234
|
+
|
|
235
|
+
# Format the select value result as markdown
|
|
236
|
+
markdown_output = f"# Select Value Update Result\n\n"
|
|
237
|
+
markdown_output += f"**Status:** {result['status']}\n"
|
|
238
|
+
markdown_output += f"**Current URL:** {result['current_url']}\n"
|
|
239
|
+
markdown_output += f"**Selector:** `{result['selector']}`\n\n"
|
|
240
|
+
|
|
241
|
+
if result["status"] == "success":
|
|
242
|
+
markdown_output += "## ✅ Success\n\n"
|
|
243
|
+
markdown_output += f"**Requested Value:** `{result['requested_value']}`\n"
|
|
244
|
+
markdown_output += f"**Selected Value:** `{result['selected_value']}`\n"
|
|
245
|
+
|
|
246
|
+
if result["requested_value"] == result["selected_value"]:
|
|
247
|
+
markdown_output += "\n✓ The select box value was successfully updated."
|
|
248
|
+
else:
|
|
249
|
+
markdown_output += "\n⚠️ Warning: The selected value differs from the requested value."
|
|
250
|
+
else:
|
|
251
|
+
markdown_output += "## ❌ Error\n\n"
|
|
252
|
+
markdown_output += f"**Error Message:** {result.get('error', 'Unknown error')}\n"
|
|
253
|
+
|
|
254
|
+
return markdown_output
|
|
255
|
+
|
|
256
|
+
async def close_browser(self, browser_id) -> str:
|
|
257
|
+
await self.browser_manager.close_browser(browser_id)
|
|
258
|
+
|
|
259
|
+
browser_closed_event = AgentEvent(
|
|
260
|
+
event_type="browser_closed", sender=self.caller.agent_name, content={"browser_id": browser_id}
|
|
261
|
+
)
|
|
262
|
+
await self.connection_manager.broadcast_event(browser_closed_event, self.workspace_id, self.thread_id)
|
|
263
|
+
|
|
264
|
+
return f"Browser with ID {browser_id} closed."
|
|
265
|
+
|
|
266
|
+
async def cleanup(self) -> None:
|
|
267
|
+
"""
|
|
268
|
+
Clean up all browser resources.
|
|
269
|
+
This should be called when the tool is being destroyed.
|
|
270
|
+
"""
|
|
271
|
+
await self.browser_manager.cleanup_all_browsers()
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
|
|
7
|
+
from .base_tool import BaseTool
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BuildTool(BaseTool):
|
|
11
|
+
"""
|
|
12
|
+
Provides backend and frontend build operations driven by .kolega-manifest.yaml.
|
|
13
|
+
|
|
14
|
+
Resolves the appropriate build command and executes it via the injected
|
|
15
|
+
TerminalManager so it works in both local and sandbox environments.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def _read_manifest(self) -> dict:
|
|
19
|
+
"""
|
|
20
|
+
Read the project manifest from the repository root.
|
|
21
|
+
|
|
22
|
+
Returns an empty dict when the file is missing or invalid.
|
|
23
|
+
"""
|
|
24
|
+
manifest_path = ".kolega-manifest.yaml"
|
|
25
|
+
try:
|
|
26
|
+
if not self.filesystem.exists(manifest_path):
|
|
27
|
+
return {}
|
|
28
|
+
content = self.filesystem.read_text(manifest_path)
|
|
29
|
+
data = yaml.safe_load(content) or {}
|
|
30
|
+
return data if isinstance(data, dict) else {}
|
|
31
|
+
except Exception:
|
|
32
|
+
return {}
|
|
33
|
+
|
|
34
|
+
@staticmethod
|
|
35
|
+
def _get_build_command(manifest: dict, kind: str) -> Optional[str]:
|
|
36
|
+
"""
|
|
37
|
+
Resolve the build command from the manifest.
|
|
38
|
+
|
|
39
|
+
kind: 'backend' | 'frontend'
|
|
40
|
+
"""
|
|
41
|
+
if not isinstance(manifest, dict):
|
|
42
|
+
return None
|
|
43
|
+
if kind == "backend":
|
|
44
|
+
return manifest.get("backend_build_command") or manifest.get("build_command")
|
|
45
|
+
if kind == "frontend":
|
|
46
|
+
return manifest.get("frontend_build_command") or manifest.get("build_command")
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
async def _run_build(self, kind: str) -> str:
|
|
50
|
+
"""
|
|
51
|
+
Execute the resolved build command and return markdown-formatted output.
|
|
52
|
+
"""
|
|
53
|
+
manifest = self._read_manifest()
|
|
54
|
+
command = self._get_build_command(manifest, kind)
|
|
55
|
+
|
|
56
|
+
if not command:
|
|
57
|
+
return f"Error: No {kind}_build_command or build_command found in .kolega-manifest.yaml"
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
output = await self.terminal_manager.run_command(
|
|
61
|
+
command=command,
|
|
62
|
+
cwd=str(self.project_path),
|
|
63
|
+
timeout=1800,
|
|
64
|
+
)
|
|
65
|
+
except Exception as exc:
|
|
66
|
+
return f"Build failed to start: {str(exc)}"
|
|
67
|
+
|
|
68
|
+
return f"""Ran {kind} build command:
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
{command}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Output:
|
|
75
|
+
```
|
|
76
|
+
{output}
|
|
77
|
+
```"""
|
|
78
|
+
|
|
79
|
+
async def build_backend(self) -> str:
|
|
80
|
+
"""
|
|
81
|
+
Build the backend using the manifest command (backend_build_command → build_command).
|
|
82
|
+
|
|
83
|
+
Returns the combined stdout/stderr output as markdown.
|
|
84
|
+
"""
|
|
85
|
+
return await self._run_build("backend")
|
|
86
|
+
|
|
87
|
+
async def build_frontend(self) -> str:
|
|
88
|
+
"""
|
|
89
|
+
Build the frontend using the manifest command (frontend_build_command → build_command).
|
|
90
|
+
|
|
91
|
+
Returns the combined stdout/stderr output as markdown.
|
|
92
|
+
"""
|
|
93
|
+
return await self._run_build("frontend")
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from .base_tool import BaseTool
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CreateFileTool(BaseTool):
|
|
5
|
+
async def create_file(self, relative_path: str, content: str) -> str:
|
|
6
|
+
"""
|
|
7
|
+
Create a new file with the specified content.
|
|
8
|
+
|
|
9
|
+
Args:
|
|
10
|
+
relative_path: Path to create the file at, relative to the project root
|
|
11
|
+
content: Content to write to the file
|
|
12
|
+
|
|
13
|
+
Returns:
|
|
14
|
+
A success message with the file content formatted as markdown
|
|
15
|
+
|
|
16
|
+
Raises:
|
|
17
|
+
FileExistsError: If the file already exists
|
|
18
|
+
ValueError: If the parent directory doesn't exist
|
|
19
|
+
PermissionError: If the file cannot be created
|
|
20
|
+
Exception: If there is a general error creating the file
|
|
21
|
+
"""
|
|
22
|
+
try:
|
|
23
|
+
# Enforce vibe policy for blacklisted basenames
|
|
24
|
+
blocked_msg = self._enforce_vibe_edit_policy(relative_path)
|
|
25
|
+
if blocked_msg:
|
|
26
|
+
return blocked_msg
|
|
27
|
+
|
|
28
|
+
# Check if file already exists
|
|
29
|
+
if self.filesystem.exists(relative_path):
|
|
30
|
+
error_msg = f"File already exists: {relative_path}"
|
|
31
|
+
await self.log_error(error_msg, sender=self.caller.agent_name)
|
|
32
|
+
return error_msg
|
|
33
|
+
|
|
34
|
+
# Create parent directory if it doesn't exist
|
|
35
|
+
parent_dir = self.filesystem.get_parent(relative_path)
|
|
36
|
+
if parent_dir and parent_dir != "." and not self.filesystem.exists(parent_dir):
|
|
37
|
+
self.filesystem.create_directory(parent_dir)
|
|
38
|
+
|
|
39
|
+
# Create the file
|
|
40
|
+
self.filesystem.write_text(relative_path, content)
|
|
41
|
+
|
|
42
|
+
# Return success message with content
|
|
43
|
+
return f"File created successfully\n\n```\n{content}\n```"
|
|
44
|
+
|
|
45
|
+
except PermissionError:
|
|
46
|
+
error_msg = f"Permission denied: Cannot create file {relative_path}"
|
|
47
|
+
await self.log_error(error_msg, sender=self.caller.agent_name)
|
|
48
|
+
return error_msg
|
|
49
|
+
except Exception as e:
|
|
50
|
+
error_msg = "Error creating file"
|
|
51
|
+
await self.log_error(error_msg, sender=self.caller.agent_name)
|
|
52
|
+
return error_msg
|