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,643 @@
1
+ import asyncio
2
+ import os.path
3
+ import re
4
+ import time
5
+ from pathlib import Path
6
+ from typing import Optional, Tuple, Union
7
+
8
+ from .. import prompts
9
+ from kolega_code.config import AgentConfig
10
+ from kolega_code.llm.client import LLMClient
11
+ from kolega_code.llm.models import Message, MessageHistory, TextBlock
12
+ from kolega_code.llm.specs import get_model_specs
13
+ from kolega_code.events import AgentEvent
14
+ from kolega_code.services.terminal import LocalTerminalManager
15
+ from .base_tool import BaseTool
16
+
17
+
18
+ def _generate_compression_notice(terminal_id: str, full_char_count: int, threshold: int, compressed_output: str) -> str:
19
+ """
20
+ Generate a properly formatted compression notice.
21
+
22
+ Args:
23
+ terminal_id: ID of the terminal
24
+ full_char_count: Actual character count of the full output
25
+ threshold: The compression threshold that was exceeded
26
+ compressed_output: The compressed/summarized output
27
+
28
+ Returns:
29
+ Formatted compression notice string
30
+ """
31
+ # Recommend reading at the threshold limit to avoid re-compression
32
+ recommended_char_count = threshold
33
+
34
+ return f"""⚠️ **OUTPUT COMPRESSED** ⚠️
35
+
36
+ The terminal output ({full_char_count:,} characters) exceeded the compression threshold ({threshold:,} characters) and has been summarized below.
37
+
38
+ **To read uncompressed output (up to {threshold:,} characters), use:**
39
+ `read_terminal({terminal_id}, num_chars={recommended_char_count})`
40
+
41
+ **To read specific portions without compression, use the offset parameter:**
42
+ `read_terminal({terminal_id}, num_chars=<chars>, offset=<chars_from_end>)`
43
+
44
+ **Note:** Reading more than {threshold:,} characters without an offset will result in compression again.
45
+
46
+ ---
47
+
48
+ **Compressed Summary:**
49
+ {compressed_output}
50
+
51
+ ---
52
+
53
+ 💡 **Tip:** Use the read_terminal tool with {recommended_char_count:,} characters to get the most recent uncompressed output, or use the offset parameter to read specific portions.
54
+ """
55
+
56
+
57
+ class TerminalTool(BaseTool):
58
+
59
+ output_compression_threshold = 4000
60
+ DEFAULT_COMMAND_WAIT_TIMEOUT_SECONDS = 120
61
+ MAX_COMMAND_WAIT_TIMEOUT_SECONDS = 300
62
+
63
+ def __init__(
64
+ self,
65
+ project_path: Union[str, Path],
66
+ workspace_id: str,
67
+ thread_id: str,
68
+ connection_manager,
69
+ config: AgentConfig,
70
+ caller,
71
+ filesystem=None,
72
+ terminal_manager=None,
73
+ ):
74
+ super().__init__(
75
+ project_path,
76
+ workspace_id,
77
+ thread_id,
78
+ connection_manager,
79
+ config,
80
+ caller,
81
+ filesystem,
82
+ terminal_manager=terminal_manager,
83
+ )
84
+
85
+ self.auto_activate_venv = True
86
+ self.venv_activation_command = None
87
+ self.initialized = False
88
+ self.security_check_enabled = False
89
+
90
+ # Use injected terminal_manager if provided, otherwise create local one
91
+ if self.terminal_manager is None:
92
+ self.terminal_manager = LocalTerminalManager(
93
+ workspace_id=workspace_id, thread_id=thread_id, connection_manager=connection_manager
94
+ )
95
+
96
+ async def _run_command_security_check(self, command: str) -> Tuple[bool, str]:
97
+ provider = self.config.fast_config.provider
98
+ api_key = self.config.get_api_key(provider)
99
+ rate_limits = self.config.fast_config.rate_limits
100
+
101
+ client = LLMClient(
102
+ provider=provider.value,
103
+ api_key=api_key,
104
+ max_retries=rate_limits.max_retries,
105
+ requests_per_minute=rate_limits.requests_per_minute,
106
+ tokens_per_minute=rate_limits.tokens_per_minute,
107
+ )
108
+
109
+ try:
110
+ model_specs = get_model_specs(self.config.fast_config.provider, self.config.fast_config.model)
111
+
112
+ system_message = Message(role="system", content=[TextBlock(text=prompts.SHELL_SAFETY_SYSTEM_PROMPT)])
113
+
114
+ messages = MessageHistory(
115
+ [
116
+ Message(
117
+ role="user",
118
+ content=[
119
+ TextBlock(text=f"Project directory:\n{str(self.caller.project_path)}\nCommand:\n{command}")
120
+ ],
121
+ )
122
+ ]
123
+ )
124
+
125
+ response = await client.generate(
126
+ model=self.config.fast_config.model,
127
+ max_completion_tokens=model_specs["max_completion_tokens"],
128
+ system=system_message,
129
+ messages=messages,
130
+ )
131
+
132
+ response_text = response.get_text_content()
133
+
134
+ if response_text == "safe":
135
+ return True, None
136
+ else:
137
+ return False, response_text
138
+ except Exception as ex:
139
+ error_msg = f"Command not executed. Could not verify safety: {str(ex)}"
140
+ return False, error_msg
141
+
142
+ async def _compress_terminal_output(
143
+ self, output: str, last_command: str, command_purpose: Optional[str] = None
144
+ ) -> str:
145
+ provider = self.config.fast_config.provider
146
+ api_key = self.config.get_api_key(provider)
147
+ rate_limits = self.config.fast_config.rate_limits
148
+
149
+ client = LLMClient(
150
+ provider=provider.value,
151
+ api_key=api_key,
152
+ max_retries=rate_limits.max_retries,
153
+ requests_per_minute=rate_limits.requests_per_minute,
154
+ tokens_per_minute=rate_limits.tokens_per_minute,
155
+ )
156
+
157
+ try:
158
+ model_specs = get_model_specs(self.config.fast_config.provider, self.config.fast_config.model)
159
+
160
+ system_message = Message(role="system", content=[TextBlock(text=prompts.SHELL_COMPRESSION_SYSTEM_PROMPT)])
161
+
162
+ messages = MessageHistory(
163
+ [
164
+ Message(
165
+ role="user",
166
+ content=[
167
+ TextBlock(
168
+ text=f"Last command:\n{str(last_command)}\nCommand purpose:\n{command_purpose}\nOutput:\n{output}"
169
+ )
170
+ ],
171
+ )
172
+ ]
173
+ )
174
+
175
+ response = await client.generate(
176
+ model=self.config.fast_config.model,
177
+ max_completion_tokens=model_specs["max_completion_tokens"],
178
+ system=system_message,
179
+ messages=messages,
180
+ )
181
+
182
+ compressed = response.get_text_content()
183
+
184
+ return compressed
185
+ except Exception:
186
+ # Return full output as fallback if compression fails.
187
+ return output
188
+
189
+ async def launch_terminal(self, terminal_id: Optional[str] = None) -> str:
190
+ terminal_id = await self.terminal_manager.launch_terminal(cwd=self.project_path)
191
+
192
+ terminal_launched_event = AgentEvent(
193
+ event_type="terminal_launched", sender="agent", content={"terminal_id": terminal_id}
194
+ )
195
+ await self.connection_manager.broadcast_event(terminal_launched_event, self.workspace_id, self.thread_id)
196
+
197
+ return f"Launched new terminal with terminal_id {terminal_id}"
198
+
199
+ async def run_command(self, terminal_id: str, command: str, purpose: str) -> str:
200
+ if self.security_check_enabled:
201
+ allowed, denied_reason = await self._run_command_security_check(command)
202
+
203
+ if not allowed:
204
+ return denied_reason
205
+
206
+ # For sandbox environments, use timeout=0 (no timeout) to allow long-running processes
207
+ # LocalTerminalManager will ignore this parameter as it uses persistent shell sessions
208
+ success = await self.terminal_manager.send_command(terminal_id, command, purpose=purpose, timeout=0)
209
+
210
+ if success:
211
+ return f"Ran command `{command}` in terminal {terminal_id}. Use read_terminal to read the output."
212
+ else:
213
+ return f"Failed to run command `{command}` in terminal {terminal_id}. Terminal may not be running."
214
+
215
+ async def read_terminal(self, terminal_id: str, num_chars: int = 1024, offset: int = 0) -> str:
216
+ """
217
+ Read characters from a terminal's persistent output buffer.
218
+
219
+ Args:
220
+ terminal_id: ID of the terminal to read output from
221
+ num_chars: Number of characters to read (default: 1024).
222
+ If buffer is smaller than num_chars, returns entire buffer.
223
+ offset: Number of characters from the end to start reading from (default: 0).
224
+ If offset is 0, reads the last num_chars characters.
225
+ If offset is > 0, reads num_chars characters starting from that offset from the end.
226
+ Note: When offset > 0, compression is bypassed to allow reading specific portions.
227
+
228
+ Returns:
229
+ The requested characters from the terminal's output buffer, formatted in markdown code blocks.
230
+ When offset is used, compression is skipped to preserve the exact requested content.
231
+ """
232
+ output = self.terminal_manager.read_output(terminal_id, num_chars=num_chars, offset=offset)
233
+ output_ansi_stripped = self._strip_ansi_codes(output)
234
+
235
+ # Skip compression when using offset to allow reading specific portions
236
+ if offset > 0:
237
+ return f"```\n{output_ansi_stripped}```\n"
238
+
239
+ # Apply compression logic only when reading from the end (offset = 0)
240
+ if len(output_ansi_stripped) > self.output_compression_threshold:
241
+ last_command = await self.terminal_manager.get_last_command(terminal_id)
242
+ command_purpose = await self.terminal_manager.get_last_command_purpose(terminal_id)
243
+ compressed_output = await self._compress_terminal_output(
244
+ output_ansi_stripped, last_command, command_purpose
245
+ )
246
+
247
+ # Calculate the full character count
248
+ full_char_count = len(output_ansi_stripped)
249
+
250
+ compression_notice = _generate_compression_notice(
251
+ terminal_id, full_char_count, self.output_compression_threshold, compressed_output
252
+ )
253
+ return compression_notice
254
+
255
+ return f"```\n{output_ansi_stripped}```\n"
256
+
257
+ async def close_terminal(self, terminal_id: str) -> str:
258
+ await self.terminal_manager.close_terminal(terminal_id)
259
+
260
+ terminal_closed_event = AgentEvent(
261
+ event_type="terminal_closed", sender="agent", content={"terminal_id": terminal_id}
262
+ )
263
+ await self.connection_manager.broadcast_event(terminal_closed_event, self.workspace_id, self.thread_id)
264
+
265
+ return f"Terminal with ID {terminal_id} closed."
266
+
267
+ async def list_terminals(self):
268
+ results = await self.terminal_manager.list_terminals()
269
+
270
+ formatted_results = "# Terminal Sessions\n\n"
271
+
272
+ if not results:
273
+ formatted_results += "No active terminals found.\n"
274
+ else:
275
+ formatted_results += "| Terminal ID | Status | Last Command |\n"
276
+ formatted_results += "|-------------|--------|-------------|\n"
277
+
278
+ for terminal_id, terminal_info in results.items():
279
+ status = "Running" if terminal_info["running"] else "Stopped"
280
+ last_command = terminal_info["last_command"] or "None"
281
+ # Truncate long commands for better display
282
+ if len(last_command) > 50:
283
+ last_command = last_command[:47] + "..."
284
+ formatted_results += f"| {terminal_id} | {status} | {last_command} |\n"
285
+
286
+ return formatted_results
287
+
288
+ async def execute_terminal_command(self, command: str, strip_colors: bool = True) -> str:
289
+ """
290
+ Execute a command and display output in terminal.
291
+
292
+ Args:
293
+ command: The command to execute
294
+ strip_colors: Whether to strip ANSI color codes from output (default: True)
295
+ """
296
+ # Log the command
297
+ await self.log_info(f"Executing command: {command}", sender=self.caller.agent_name)
298
+
299
+ # Check security if enabled
300
+ if self.security_check_enabled:
301
+ allowed, denied_reason = await self._run_command_security_check(command)
302
+ if not allowed:
303
+ await self.log_error(
304
+ f"Command blocked by security check: {denied_reason}", sender=self.caller.agent_name
305
+ )
306
+ return f"Command execution blocked: {denied_reason}"
307
+
308
+ # Initialize terminal environment if not already done
309
+ if not self.initialized and self.auto_activate_venv:
310
+ await self.initialize_terminal()
311
+
312
+ # Prepend virtual environment activation command if available and requested
313
+ full_command = command
314
+ if self.venv_activation_command:
315
+ # Use a subshell to maintain environment for this command
316
+ full_command = f"(source {self.venv_activation_command} && {command})"
317
+
318
+ try:
319
+ # Send command to terminal (show the original command to the user)
320
+ terminal_command_event = AgentEvent(
321
+ event_type="terminal_command", sender="agent", content={"command": command}
322
+ )
323
+ await self.connection_manager.broadcast_event(terminal_command_event, self.workspace_id, self.thread_id)
324
+
325
+ # Execute the command (with potential venv activation)
326
+ process = await asyncio.create_subprocess_shell(
327
+ full_command,
328
+ stdout=asyncio.subprocess.PIPE,
329
+ stderr=asyncio.subprocess.PIPE,
330
+ cwd=str(self.project_path),
331
+ shell=True,
332
+ )
333
+
334
+ try:
335
+ # Set a timeout of 15 seconds
336
+ stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=15)
337
+ output = stdout.decode() + stderr.decode()
338
+
339
+ # Strip ANSI color codes if requested
340
+ if strip_colors:
341
+ output = self._strip_ansi_codes(output)
342
+
343
+ except asyncio.TimeoutError:
344
+ # If the command doesn't return after 15 seconds
345
+ output = "Command timed out after 15 seconds"
346
+ await self.log_info(f"Command timed out: {command}", sender=self.caller.agent_name)
347
+
348
+ # Try to terminate the process
349
+ try:
350
+ process.terminate()
351
+ except Exception:
352
+ pass
353
+
354
+ terminal_output_event = AgentEvent(event_type="terminal_output", sender="agent", content={"output": output})
355
+ await self.connection_manager.broadcast_event(terminal_output_event, self.workspace_id, self.thread_id)
356
+
357
+ return output
358
+ except Exception as e:
359
+ error_msg = f"Command execution failed: {str(e)}"
360
+ await self.log_error(error_msg, sender=self.caller.agent_name)
361
+ return error_msg
362
+
363
+ def _strip_ansi_codes(self, text: str) -> str:
364
+ """
365
+ Remove ANSI escape sequences (color/formatting codes) from text.
366
+
367
+ Args:
368
+ text: The text containing ANSI codes
369
+
370
+ Returns:
371
+ Text with ANSI codes removed
372
+ """
373
+ ansi_escape = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
374
+ return ansi_escape.sub("", text)
375
+
376
+ def configure(self, auto_activate_venv: bool = None, security_check_enabled: bool = None) -> None:
377
+ """
378
+ Configure the terminal tool settings.
379
+
380
+ Args:
381
+ auto_activate_venv: Whether to automatically detect and activate virtual environments
382
+ security_check_enabled: Whether to perform security checks on commands before execution
383
+ """
384
+ if auto_activate_venv is not None:
385
+ self.auto_activate_venv = auto_activate_venv
386
+ # Reset initialization state if configuration changes
387
+ if not auto_activate_venv:
388
+ self.venv_activation_command = None
389
+ self.initialized = False
390
+
391
+ if security_check_enabled is not None:
392
+ self.security_check_enabled = security_check_enabled
393
+
394
+ async def detect_venv(self) -> str:
395
+ """
396
+ Detect if a Python virtual environment exists in the project directory.
397
+
398
+ Returns:
399
+ Path to the activation script if a virtual environment was found, empty string otherwise
400
+ """
401
+ if not self.auto_activate_venv:
402
+ await self.log_info("Virtual environment auto-activation is disabled", sender=self.caller.agent_name)
403
+ return ""
404
+
405
+ # Common virtual environment directory names
406
+ venv_dirs = [".venv", "venv", "env", ".env"]
407
+
408
+ for venv_dir in venv_dirs:
409
+ # Check if the virtual environment directory exists
410
+ venv_path = os.path.join(str(self.project_path), venv_dir)
411
+
412
+ # Check if directory exists
413
+ if not os.path.isdir(venv_path):
414
+ continue
415
+
416
+ # Check for the activation script based on OS
417
+ activate_script = os.path.join(venv_path, "bin", "activate")
418
+ windows_script = os.path.join(venv_path, "Scripts", "activate")
419
+
420
+ if os.path.isfile(activate_script):
421
+ await self.log_info(f"Found virtual environment at {venv_dir}", sender=self.caller.agent_name)
422
+ return activate_script
423
+
424
+ if os.path.isfile(windows_script):
425
+ await self.log_info(
426
+ f"Found virtual environment at {venv_dir} (Windows style)", sender=self.caller.agent_name
427
+ )
428
+ return windows_script
429
+
430
+ await self.log_info("No virtual environment found in the project directory", sender=self.caller.agent_name)
431
+ return ""
432
+
433
+ async def initialize_terminal(self) -> None:
434
+ """
435
+ Initialize the terminal by detecting virtual environments.
436
+ Sets the venv_activation_command for use in subsequent commands.
437
+ """
438
+ await self.log_info("Initializing terminal environment...", sender=self.caller.agent_name)
439
+
440
+ # Detect virtual environment
441
+ activation_script = await self.detect_venv()
442
+
443
+ if activation_script:
444
+ self.venv_activation_command = activation_script
445
+ await self.log_info(
446
+ f"Virtual environment activation script found at: {activation_script}", sender=self.caller.agent_name
447
+ )
448
+ else:
449
+ self.venv_activation_command = None
450
+
451
+ self.initialized = True
452
+ await self.log_info("Terminal initialization complete", sender=self.caller.agent_name)
453
+
454
+ async def run_command_tracked(self, terminal_id: str, command: str, purpose: str) -> str:
455
+ """
456
+ Run a command with tracking and return a command ID.
457
+
458
+ This version provides reliable command completion detection by monitoring
459
+ the actual process status rather than interpreting output.
460
+
461
+ Args:
462
+ terminal_id: The terminal to run the command in
463
+ command: The command to execute
464
+ purpose: Description of what the command is meant to do
465
+
466
+ Returns:
467
+ Command ID that can be used to check status, or error message
468
+ """
469
+ if self.security_check_enabled:
470
+ allowed, denied_reason = await self._run_command_security_check(command)
471
+ if not allowed:
472
+ return denied_reason
473
+
474
+ try:
475
+ # For sandbox environments, use timeout=0 (no timeout) to allow long-running processes
476
+ command_id = await self.terminal_manager.send_command_tracked(terminal_id, command, purpose, timeout=0)
477
+ if command_id:
478
+ return command_id # Return just the command ID, not a message
479
+ else:
480
+ return f"Failed to start command `{command}` in terminal {terminal_id}. Terminal may not be running."
481
+ except KeyError as e:
482
+ return str(e)
483
+
484
+ async def send_terminal_input(
485
+ self, terminal_id: str, text: str, submit: bool = True, command_id: Optional[str] = None
486
+ ) -> str:
487
+ """
488
+ Send input to an already-running command in a terminal.
489
+
490
+ Args:
491
+ terminal_id: The terminal where the command is running
492
+ text: Text to send to the process
493
+ submit: Whether to append a newline before sending
494
+ command_id: Optional command ID when more than one command is active
495
+
496
+ Returns:
497
+ Confirmation that input was sent, or a readable error
498
+ """
499
+ try:
500
+ success = await self.terminal_manager.send_input(
501
+ terminal_id, text, submit=submit, command_id=command_id
502
+ )
503
+ except (KeyError, ValueError) as e:
504
+ return str(e)
505
+
506
+ if not success:
507
+ return f"Failed to send input to terminal {terminal_id}. Terminal may not be running."
508
+
509
+ command_suffix = f" for command {command_id}" if command_id else ""
510
+ submit_suffix = " and submitted it" if submit else ""
511
+ return f"Sent input to terminal {terminal_id}{command_suffix}{submit_suffix}."
512
+
513
+ async def check_command_status(self, terminal_id: str, command_id: str) -> str:
514
+ """
515
+ Check the status of a specific command using process monitoring.
516
+
517
+ This provides reliable completion detection without LLM interpretation.
518
+
519
+ Args:
520
+ terminal_id: The terminal the command is running in
521
+ command_id: The command ID returned from run_command_tracked
522
+
523
+ Returns:
524
+ Formatted status information including completion state
525
+ """
526
+ try:
527
+ status = self.terminal_manager.get_command_status(terminal_id, command_id)
528
+
529
+ if status["status"] == "not_found":
530
+ return f"❌ Command ID {command_id} not found"
531
+
532
+ duration_str = f"{status['duration']:.1f}s"
533
+
534
+ if status["status"] == "running":
535
+ child_pids = status.get("child_pids") or []
536
+ child_info = f" ({len(child_pids)} child processes)" if child_pids else ""
537
+ return (
538
+ f"🔄 Command still running in terminal {terminal_id} after {duration_str}{child_info}\n"
539
+ f"Command: {status['command']}"
540
+ )
541
+ elif status["status"] == "completed":
542
+ return_code = status.get("return_code", "unknown")
543
+ return (
544
+ f"✅ Command completed in {duration_str} with exit code {return_code}\nCommand: {status['command']}"
545
+ )
546
+ elif status["status"] == "terminated":
547
+ return f"❌ Command terminated after {duration_str}\nCommand: {status['command']}"
548
+ elif status["status"] == "failed":
549
+ return_code = status.get("return_code", 1)
550
+ return f"❌ Command failed in {duration_str} with exit code {return_code}\nCommand: {status['command']}"
551
+ elif status["status"] == "monitor_timeout":
552
+ return (
553
+ f"⚠️ Command monitoring stopped after {duration_str}; command may still be running in "
554
+ f"terminal {terminal_id}.\nCommand: {status['command']}\n"
555
+ f'Use `check_command_status("{terminal_id}", "{command_id}")` to check it again.'
556
+ )
557
+ else:
558
+ return f"❓ Command status: {status['status']} after {duration_str}\nCommand: {status['command']}"
559
+
560
+ except KeyError as e:
561
+ return str(e)
562
+
563
+ async def check_terminal_status(self, terminal_id: str) -> str:
564
+ """
565
+ Get comprehensive status of a terminal including all active commands.
566
+
567
+ Args:
568
+ terminal_id: The terminal to check
569
+
570
+ Returns:
571
+ Formatted terminal status including readiness and active commands
572
+ """
573
+ try:
574
+ status = await self.terminal_manager.get_terminal_status(terminal_id)
575
+
576
+ result = f"# Terminal {terminal_id} Status\n\n"
577
+ result += f"**Running:** {'Yes' if status['running'] else 'No'}\n"
578
+ result += f"**Ready for new commands:** {'Yes' if status['ready_for_commands'] else 'No'}\n\n"
579
+
580
+ active_commands = status["active_commands"]
581
+ if active_commands:
582
+ result += "**Active Commands:**\n"
583
+ for cmd_id, cmd_status in active_commands.items():
584
+ duration = f"{cmd_status['duration']:.1f}s"
585
+ result += f"- `{cmd_id}`: {cmd_status['command']} (running {duration})\n"
586
+ else:
587
+ result += "**Active Commands:** None\n"
588
+
589
+ return result
590
+
591
+ except KeyError as e:
592
+ return str(e)
593
+
594
+ def _normalize_command_wait_timeout(self, timeout: Optional[int]) -> int:
595
+ try:
596
+ wait_timeout = int(timeout)
597
+ except (TypeError, ValueError):
598
+ return self.DEFAULT_COMMAND_WAIT_TIMEOUT_SECONDS
599
+
600
+ if wait_timeout <= 0:
601
+ return self.DEFAULT_COMMAND_WAIT_TIMEOUT_SECONDS
602
+ return min(wait_timeout, self.MAX_COMMAND_WAIT_TIMEOUT_SECONDS)
603
+
604
+ def _format_command_wait_timeout(self, terminal_id: str, command_id: str, timeout: int) -> str:
605
+ return (
606
+ f"⏰ Timeout: Command {command_id} is still running in terminal {terminal_id} after {timeout} seconds.\n"
607
+ f'Use `check_command_status("{terminal_id}", "{command_id}")` to check it again, '
608
+ f'or `close_terminal("{terminal_id}")` if you want to stop that terminal.'
609
+ )
610
+
611
+ async def wait_for_command_completion(
612
+ self, terminal_id: str, command_id: str, timeout: Optional[int] = DEFAULT_COMMAND_WAIT_TIMEOUT_SECONDS
613
+ ) -> str:
614
+ """
615
+ Wait for a specific command to complete with reliable process monitoring.
616
+
617
+ Args:
618
+ terminal_id: The terminal the command is running in
619
+ command_id: The command ID to wait for
620
+ timeout: Maximum time to wait in seconds
621
+
622
+ Returns:
623
+ Completion status or timeout message
624
+ """
625
+ wait_timeout = self._normalize_command_wait_timeout(timeout)
626
+ start_time = time.time()
627
+
628
+ while time.time() - start_time < wait_timeout:
629
+ try:
630
+ status = self.terminal_manager.get_command_status(terminal_id, command_id)
631
+
632
+ if status["status"] in ["completed", "terminated", "not_found", "failed", "monitor_timeout"]:
633
+ return await self.check_command_status(terminal_id, command_id)
634
+
635
+ except KeyError as e:
636
+ return str(e)
637
+
638
+ remaining = wait_timeout - (time.time() - start_time)
639
+ if remaining <= 0:
640
+ break
641
+ await asyncio.sleep(min(1, remaining)) # Check every second
642
+
643
+ return self._format_command_wait_timeout(terminal_id, command_id, wait_timeout)