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