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,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