tunacode-cli 0.1.21__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.
Potentially problematic release.
This version of tunacode-cli might be problematic. Click here for more details.
- tunacode/__init__.py +0 -0
- tunacode/cli/textual_repl.tcss +283 -0
- tunacode/configuration/__init__.py +1 -0
- tunacode/configuration/defaults.py +45 -0
- tunacode/configuration/models.py +147 -0
- tunacode/configuration/models_registry.json +1 -0
- tunacode/configuration/pricing.py +74 -0
- tunacode/configuration/settings.py +35 -0
- tunacode/constants.py +227 -0
- tunacode/core/__init__.py +6 -0
- tunacode/core/agents/__init__.py +39 -0
- tunacode/core/agents/agent_components/__init__.py +48 -0
- tunacode/core/agents/agent_components/agent_config.py +441 -0
- tunacode/core/agents/agent_components/agent_helpers.py +290 -0
- tunacode/core/agents/agent_components/message_handler.py +99 -0
- tunacode/core/agents/agent_components/node_processor.py +477 -0
- tunacode/core/agents/agent_components/response_state.py +129 -0
- tunacode/core/agents/agent_components/result_wrapper.py +51 -0
- tunacode/core/agents/agent_components/state_transition.py +112 -0
- tunacode/core/agents/agent_components/streaming.py +271 -0
- tunacode/core/agents/agent_components/task_completion.py +40 -0
- tunacode/core/agents/agent_components/tool_buffer.py +44 -0
- tunacode/core/agents/agent_components/tool_executor.py +101 -0
- tunacode/core/agents/agent_components/truncation_checker.py +37 -0
- tunacode/core/agents/delegation_tools.py +109 -0
- tunacode/core/agents/main.py +545 -0
- tunacode/core/agents/prompts.py +66 -0
- tunacode/core/agents/research_agent.py +231 -0
- tunacode/core/compaction.py +218 -0
- tunacode/core/prompting/__init__.py +27 -0
- tunacode/core/prompting/loader.py +66 -0
- tunacode/core/prompting/prompting_engine.py +98 -0
- tunacode/core/prompting/sections.py +50 -0
- tunacode/core/prompting/templates.py +69 -0
- tunacode/core/state.py +409 -0
- tunacode/exceptions.py +313 -0
- tunacode/indexing/__init__.py +5 -0
- tunacode/indexing/code_index.py +432 -0
- tunacode/indexing/constants.py +86 -0
- tunacode/lsp/__init__.py +112 -0
- tunacode/lsp/client.py +351 -0
- tunacode/lsp/diagnostics.py +19 -0
- tunacode/lsp/servers.py +101 -0
- tunacode/prompts/default_prompt.md +952 -0
- tunacode/prompts/research/sections/agent_role.xml +5 -0
- tunacode/prompts/research/sections/constraints.xml +14 -0
- tunacode/prompts/research/sections/output_format.xml +57 -0
- tunacode/prompts/research/sections/tool_use.xml +23 -0
- tunacode/prompts/sections/advanced_patterns.xml +255 -0
- tunacode/prompts/sections/agent_role.xml +8 -0
- tunacode/prompts/sections/completion.xml +10 -0
- tunacode/prompts/sections/critical_rules.xml +37 -0
- tunacode/prompts/sections/examples.xml +220 -0
- tunacode/prompts/sections/output_style.xml +94 -0
- tunacode/prompts/sections/parallel_exec.xml +105 -0
- tunacode/prompts/sections/search_pattern.xml +100 -0
- tunacode/prompts/sections/system_info.xml +6 -0
- tunacode/prompts/sections/tool_use.xml +84 -0
- tunacode/prompts/sections/user_instructions.xml +3 -0
- tunacode/py.typed +0 -0
- tunacode/templates/__init__.py +5 -0
- tunacode/templates/loader.py +15 -0
- tunacode/tools/__init__.py +10 -0
- tunacode/tools/authorization/__init__.py +29 -0
- tunacode/tools/authorization/context.py +32 -0
- tunacode/tools/authorization/factory.py +20 -0
- tunacode/tools/authorization/handler.py +58 -0
- tunacode/tools/authorization/notifier.py +35 -0
- tunacode/tools/authorization/policy.py +19 -0
- tunacode/tools/authorization/requests.py +119 -0
- tunacode/tools/authorization/rules.py +72 -0
- tunacode/tools/bash.py +222 -0
- tunacode/tools/decorators.py +213 -0
- tunacode/tools/glob.py +353 -0
- tunacode/tools/grep.py +468 -0
- tunacode/tools/grep_components/__init__.py +9 -0
- tunacode/tools/grep_components/file_filter.py +93 -0
- tunacode/tools/grep_components/pattern_matcher.py +158 -0
- tunacode/tools/grep_components/result_formatter.py +87 -0
- tunacode/tools/grep_components/search_result.py +34 -0
- tunacode/tools/list_dir.py +205 -0
- tunacode/tools/prompts/bash_prompt.xml +10 -0
- tunacode/tools/prompts/glob_prompt.xml +7 -0
- tunacode/tools/prompts/grep_prompt.xml +10 -0
- tunacode/tools/prompts/list_dir_prompt.xml +7 -0
- tunacode/tools/prompts/read_file_prompt.xml +9 -0
- tunacode/tools/prompts/todoclear_prompt.xml +12 -0
- tunacode/tools/prompts/todoread_prompt.xml +16 -0
- tunacode/tools/prompts/todowrite_prompt.xml +28 -0
- tunacode/tools/prompts/update_file_prompt.xml +9 -0
- tunacode/tools/prompts/web_fetch_prompt.xml +11 -0
- tunacode/tools/prompts/write_file_prompt.xml +7 -0
- tunacode/tools/react.py +111 -0
- tunacode/tools/read_file.py +68 -0
- tunacode/tools/todo.py +222 -0
- tunacode/tools/update_file.py +62 -0
- tunacode/tools/utils/__init__.py +1 -0
- tunacode/tools/utils/ripgrep.py +311 -0
- tunacode/tools/utils/text_match.py +352 -0
- tunacode/tools/web_fetch.py +245 -0
- tunacode/tools/write_file.py +34 -0
- tunacode/tools/xml_helper.py +34 -0
- tunacode/types/__init__.py +166 -0
- tunacode/types/base.py +94 -0
- tunacode/types/callbacks.py +53 -0
- tunacode/types/dataclasses.py +121 -0
- tunacode/types/pydantic_ai.py +31 -0
- tunacode/types/state.py +122 -0
- tunacode/ui/__init__.py +6 -0
- tunacode/ui/app.py +542 -0
- tunacode/ui/commands/__init__.py +430 -0
- tunacode/ui/components/__init__.py +1 -0
- tunacode/ui/headless/__init__.py +5 -0
- tunacode/ui/headless/output.py +72 -0
- tunacode/ui/main.py +252 -0
- tunacode/ui/renderers/__init__.py +41 -0
- tunacode/ui/renderers/errors.py +197 -0
- tunacode/ui/renderers/panels.py +550 -0
- tunacode/ui/renderers/search.py +314 -0
- tunacode/ui/renderers/tools/__init__.py +21 -0
- tunacode/ui/renderers/tools/bash.py +247 -0
- tunacode/ui/renderers/tools/diagnostics.py +186 -0
- tunacode/ui/renderers/tools/glob.py +226 -0
- tunacode/ui/renderers/tools/grep.py +228 -0
- tunacode/ui/renderers/tools/list_dir.py +198 -0
- tunacode/ui/renderers/tools/read_file.py +226 -0
- tunacode/ui/renderers/tools/research.py +294 -0
- tunacode/ui/renderers/tools/update_file.py +237 -0
- tunacode/ui/renderers/tools/web_fetch.py +182 -0
- tunacode/ui/repl_support.py +226 -0
- tunacode/ui/screens/__init__.py +16 -0
- tunacode/ui/screens/model_picker.py +303 -0
- tunacode/ui/screens/session_picker.py +181 -0
- tunacode/ui/screens/setup.py +218 -0
- tunacode/ui/screens/theme_picker.py +90 -0
- tunacode/ui/screens/update_confirm.py +69 -0
- tunacode/ui/shell_runner.py +129 -0
- tunacode/ui/styles/layout.tcss +98 -0
- tunacode/ui/styles/modals.tcss +38 -0
- tunacode/ui/styles/panels.tcss +81 -0
- tunacode/ui/styles/theme-nextstep.tcss +303 -0
- tunacode/ui/styles/widgets.tcss +33 -0
- tunacode/ui/styles.py +18 -0
- tunacode/ui/widgets/__init__.py +23 -0
- tunacode/ui/widgets/command_autocomplete.py +62 -0
- tunacode/ui/widgets/editor.py +402 -0
- tunacode/ui/widgets/file_autocomplete.py +47 -0
- tunacode/ui/widgets/messages.py +46 -0
- tunacode/ui/widgets/resource_bar.py +182 -0
- tunacode/ui/widgets/status_bar.py +98 -0
- tunacode/utils/__init__.py +0 -0
- tunacode/utils/config/__init__.py +13 -0
- tunacode/utils/config/user_configuration.py +91 -0
- tunacode/utils/messaging/__init__.py +10 -0
- tunacode/utils/messaging/message_utils.py +34 -0
- tunacode/utils/messaging/token_counter.py +77 -0
- tunacode/utils/parsing/__init__.py +13 -0
- tunacode/utils/parsing/command_parser.py +55 -0
- tunacode/utils/parsing/json_utils.py +188 -0
- tunacode/utils/parsing/retry.py +146 -0
- tunacode/utils/parsing/tool_parser.py +267 -0
- tunacode/utils/security/__init__.py +15 -0
- tunacode/utils/security/command.py +106 -0
- tunacode/utils/system/__init__.py +25 -0
- tunacode/utils/system/gitignore.py +155 -0
- tunacode/utils/system/paths.py +190 -0
- tunacode/utils/ui/__init__.py +9 -0
- tunacode/utils/ui/file_filter.py +135 -0
- tunacode/utils/ui/helpers.py +24 -0
- tunacode_cli-0.1.21.dist-info/METADATA +170 -0
- tunacode_cli-0.1.21.dist-info/RECORD +174 -0
- tunacode_cli-0.1.21.dist-info/WHEEL +4 -0
- tunacode_cli-0.1.21.dist-info/entry_points.txt +2 -0
- tunacode_cli-0.1.21.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import difflib
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
from tunacode.constants import MAX_CALLBACK_CONTENT, MAX_PANEL_LINE_WIDTH
|
|
7
|
+
from tunacode.tools.utils.text_match import replace
|
|
8
|
+
from tunacode.types import ToolArgs, ToolConfirmationRequest, ToolName
|
|
9
|
+
|
|
10
|
+
MAX_PREVIEW_LINES = 100
|
|
11
|
+
TRUNCATION_NOTICE = "... [truncated for safety]"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _preview_lines(text: str) -> tuple[list[str], bool]:
|
|
15
|
+
"""Return bounded preview lines for UI confirmation panels.
|
|
16
|
+
|
|
17
|
+
This must be safe for extremely large or single-line payloads (e.g., minified files)
|
|
18
|
+
so the TUI doesn't hang while rendering Rich Syntax blocks.
|
|
19
|
+
"""
|
|
20
|
+
if not text:
|
|
21
|
+
return [], False
|
|
22
|
+
|
|
23
|
+
truncated = False
|
|
24
|
+
preview = text
|
|
25
|
+
if len(preview) > MAX_CALLBACK_CONTENT:
|
|
26
|
+
preview = preview[:MAX_CALLBACK_CONTENT]
|
|
27
|
+
truncated = True
|
|
28
|
+
|
|
29
|
+
lines: list[str] = []
|
|
30
|
+
offset = 0
|
|
31
|
+
|
|
32
|
+
while len(lines) < MAX_PREVIEW_LINES and offset < len(preview):
|
|
33
|
+
newline_index = preview.find("\n", offset)
|
|
34
|
+
if newline_index == -1:
|
|
35
|
+
lines.append(preview[offset:])
|
|
36
|
+
offset = len(preview)
|
|
37
|
+
break
|
|
38
|
+
|
|
39
|
+
lines.append(preview[offset:newline_index])
|
|
40
|
+
offset = newline_index + 1
|
|
41
|
+
|
|
42
|
+
if offset < len(preview):
|
|
43
|
+
truncated = True
|
|
44
|
+
|
|
45
|
+
bounded_lines: list[str] = []
|
|
46
|
+
for line in lines:
|
|
47
|
+
if len(line) <= MAX_PANEL_LINE_WIDTH:
|
|
48
|
+
bounded_lines.append(line)
|
|
49
|
+
continue
|
|
50
|
+
|
|
51
|
+
bounded_lines.append(line[:MAX_PANEL_LINE_WIDTH] + "...")
|
|
52
|
+
truncated = True
|
|
53
|
+
|
|
54
|
+
return bounded_lines, truncated
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _generate_creation_diff(filepath: str, content: str) -> str:
|
|
58
|
+
"""Generate a unified diff for new file creation."""
|
|
59
|
+
lines, truncated = _preview_lines(content)
|
|
60
|
+
|
|
61
|
+
diff_parts = [
|
|
62
|
+
"--- /dev/null\n",
|
|
63
|
+
f"+++ b/{filepath}\n",
|
|
64
|
+
f"@@ -0,0 +1,{len(lines)} @@\n",
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
for line in lines:
|
|
68
|
+
diff_parts.append(f"+{line}\n")
|
|
69
|
+
|
|
70
|
+
if truncated:
|
|
71
|
+
diff_parts.append(f"\n{TRUNCATION_NOTICE}\n")
|
|
72
|
+
|
|
73
|
+
return "".join(diff_parts)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ConfirmationRequestFactory:
|
|
77
|
+
"""Create structured confirmation requests for UI surfaces."""
|
|
78
|
+
|
|
79
|
+
def create(self, tool_name: ToolName, args: ToolArgs) -> ToolConfirmationRequest:
|
|
80
|
+
filepath = args.get("filepath")
|
|
81
|
+
diff_content: str | None = None
|
|
82
|
+
|
|
83
|
+
if tool_name == "update_file" and filepath and os.path.exists(filepath):
|
|
84
|
+
old_text = args.get("old_text")
|
|
85
|
+
new_text = args.get("new_text")
|
|
86
|
+
if old_text and new_text:
|
|
87
|
+
try:
|
|
88
|
+
with open(filepath, encoding="utf-8") as f:
|
|
89
|
+
original = f.read()
|
|
90
|
+
|
|
91
|
+
# Attempt to generate what the new content will look like
|
|
92
|
+
new_content = replace(original, old_text, new_text, replace_all=False)
|
|
93
|
+
|
|
94
|
+
diff_lines = list(
|
|
95
|
+
difflib.unified_diff(
|
|
96
|
+
original.splitlines(keepends=True),
|
|
97
|
+
new_content.splitlines(keepends=True),
|
|
98
|
+
fromfile=f"a/{filepath}",
|
|
99
|
+
tofile=f"b/{filepath}",
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
if diff_lines:
|
|
103
|
+
raw_diff = "".join(diff_lines)
|
|
104
|
+
diff_preview_lines, truncated = _preview_lines(raw_diff)
|
|
105
|
+
diff_content = "\n".join(diff_preview_lines)
|
|
106
|
+
if truncated:
|
|
107
|
+
diff_content = f"{diff_content}\n{TRUNCATION_NOTICE}"
|
|
108
|
+
except Exception:
|
|
109
|
+
# If anything fails (file read, fuzzy match, etc), we just don't show the diff
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
elif tool_name == "write_file" and filepath:
|
|
113
|
+
content = args.get("content", "")
|
|
114
|
+
if content:
|
|
115
|
+
diff_content = _generate_creation_diff(filepath, content)
|
|
116
|
+
|
|
117
|
+
return ToolConfirmationRequest(
|
|
118
|
+
tool_name=tool_name, args=args, filepath=filepath, diff_content=diff_content
|
|
119
|
+
)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Protocol
|
|
4
|
+
|
|
5
|
+
from tunacode.constants import READ_ONLY_TOOLS
|
|
6
|
+
from tunacode.types import ToolName
|
|
7
|
+
|
|
8
|
+
from .context import AuthContext
|
|
9
|
+
|
|
10
|
+
READ_ONLY_TOOL_NAMES: set[str] = {tool.value for tool in READ_ONLY_TOOLS}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AuthorizationRule(Protocol):
|
|
14
|
+
"""Protocol for authorization rules."""
|
|
15
|
+
|
|
16
|
+
def should_allow_without_confirmation(
|
|
17
|
+
self, tool_name: ToolName, context: AuthContext
|
|
18
|
+
) -> bool: ...
|
|
19
|
+
|
|
20
|
+
def priority(self) -> int: ...
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ReadOnlyToolRule:
|
|
24
|
+
"""Read-only tools are always safe to execute."""
|
|
25
|
+
|
|
26
|
+
def priority(self) -> int:
|
|
27
|
+
return 200
|
|
28
|
+
|
|
29
|
+
def should_allow_without_confirmation(self, tool_name: ToolName, _: AuthContext) -> bool:
|
|
30
|
+
return is_read_only_tool(tool_name)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TemplateAllowedToolsRule:
|
|
34
|
+
"""Tools approved by the active template skip confirmation."""
|
|
35
|
+
|
|
36
|
+
def priority(self) -> int:
|
|
37
|
+
return 210
|
|
38
|
+
|
|
39
|
+
def should_allow_without_confirmation(self, tool_name: ToolName, context: AuthContext) -> bool:
|
|
40
|
+
if context.active_template is None:
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
allowed_tools = context.active_template.allowed_tools
|
|
44
|
+
if allowed_tools is None:
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
return tool_name in allowed_tools
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class YoloModeRule:
|
|
51
|
+
"""YOLO mode bypasses confirmations entirely."""
|
|
52
|
+
|
|
53
|
+
def priority(self) -> int:
|
|
54
|
+
return 300
|
|
55
|
+
|
|
56
|
+
def should_allow_without_confirmation(self, _tool_name: ToolName, context: AuthContext) -> bool:
|
|
57
|
+
return context.yolo_mode
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ToolIgnoreListRule:
|
|
61
|
+
"""User ignore list bypasses confirmation for selected tools."""
|
|
62
|
+
|
|
63
|
+
def priority(self) -> int:
|
|
64
|
+
return 310
|
|
65
|
+
|
|
66
|
+
def should_allow_without_confirmation(self, tool_name: ToolName, context: AuthContext) -> bool:
|
|
67
|
+
return tool_name in context.tool_ignore_list
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def is_read_only_tool(tool_name: str) -> bool:
|
|
71
|
+
"""Check if the tool is classified as read-only."""
|
|
72
|
+
return tool_name in READ_ONLY_TOOL_NAMES
|
tunacode/tools/bash.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Bash command execution tool for agent operations."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
|
|
8
|
+
from pydantic_ai.exceptions import ModelRetry
|
|
9
|
+
|
|
10
|
+
from tunacode.constants import (
|
|
11
|
+
CMD_OUTPUT_TRUNCATED,
|
|
12
|
+
COMMAND_OUTPUT_END_SIZE,
|
|
13
|
+
COMMAND_OUTPUT_START_INDEX,
|
|
14
|
+
COMMAND_OUTPUT_THRESHOLD,
|
|
15
|
+
MAX_COMMAND_OUTPUT,
|
|
16
|
+
)
|
|
17
|
+
from tunacode.tools.decorators import base_tool
|
|
18
|
+
|
|
19
|
+
# Enhanced dangerous patterns from run_command.py
|
|
20
|
+
DESTRUCTIVE_PATTERNS = ["rm -rf", "rm -r", "rm /", "dd if=", "mkfs", "fdisk"]
|
|
21
|
+
|
|
22
|
+
# Comprehensive dangerous patterns from security module
|
|
23
|
+
DANGEROUS_PATTERNS = [
|
|
24
|
+
r"rm\s+-rf\s+/", # Dangerous rm commands
|
|
25
|
+
r"sudo\s+rm", # Sudo rm commands
|
|
26
|
+
r">\s*/dev/sd[a-z]", # Writing to disk devices
|
|
27
|
+
r"dd\s+.*of=/dev/", # DD to devices
|
|
28
|
+
r"mkfs\.", # Format filesystem
|
|
29
|
+
r"fdisk", # Partition manipulation
|
|
30
|
+
r":\(\)\{.*\}\;", # Fork bomb pattern
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@base_tool
|
|
35
|
+
async def bash(
|
|
36
|
+
command: str,
|
|
37
|
+
cwd: str | None = None,
|
|
38
|
+
env: dict[str, str] | None = None,
|
|
39
|
+
timeout: int | None = 30,
|
|
40
|
+
capture_output: bool = True,
|
|
41
|
+
) -> str:
|
|
42
|
+
"""Execute a bash command with enhanced features.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
command: The bash command to execute.
|
|
46
|
+
cwd: Working directory for the command.
|
|
47
|
+
env: Additional environment variables to set.
|
|
48
|
+
timeout: Command timeout in seconds (1-300, default 30).
|
|
49
|
+
capture_output: Whether to capture stdout/stderr.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
Formatted output with exit code, stdout, and stderr.
|
|
53
|
+
"""
|
|
54
|
+
_validate_inputs(command, cwd, timeout)
|
|
55
|
+
_validate_command_security(command)
|
|
56
|
+
|
|
57
|
+
exec_env = os.environ.copy()
|
|
58
|
+
if env:
|
|
59
|
+
for key, value in env.items():
|
|
60
|
+
if isinstance(key, str) and isinstance(value, str):
|
|
61
|
+
exec_env[key] = value
|
|
62
|
+
|
|
63
|
+
exec_cwd = cwd or os.getcwd()
|
|
64
|
+
|
|
65
|
+
process = None
|
|
66
|
+
try:
|
|
67
|
+
process = await asyncio.create_subprocess_shell(
|
|
68
|
+
command,
|
|
69
|
+
stdout=subprocess.PIPE if capture_output else None,
|
|
70
|
+
stderr=subprocess.PIPE if capture_output else None,
|
|
71
|
+
cwd=exec_cwd,
|
|
72
|
+
env=exec_env,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
|
|
77
|
+
except TimeoutError as err:
|
|
78
|
+
process.kill()
|
|
79
|
+
await process.wait()
|
|
80
|
+
raise ModelRetry(
|
|
81
|
+
f"Command timed out after {timeout} seconds: {command}\n"
|
|
82
|
+
"Consider using a longer timeout or breaking the command into smaller parts."
|
|
83
|
+
) from err
|
|
84
|
+
|
|
85
|
+
stdout_text = stdout.decode("utf-8", errors="replace").strip() if stdout else ""
|
|
86
|
+
stderr_text = stderr.decode("utf-8", errors="replace").strip() if stderr else ""
|
|
87
|
+
|
|
88
|
+
return_code = process.returncode
|
|
89
|
+
assert return_code is not None
|
|
90
|
+
|
|
91
|
+
_check_common_errors(command, return_code, stderr_text)
|
|
92
|
+
|
|
93
|
+
return _format_output(command, return_code, stdout_text, stderr_text, exec_cwd)
|
|
94
|
+
|
|
95
|
+
except FileNotFoundError as err:
|
|
96
|
+
raise ModelRetry(
|
|
97
|
+
f"Shell not found. Cannot execute command: {command}\n"
|
|
98
|
+
"This typically indicates a system configuration issue."
|
|
99
|
+
) from err
|
|
100
|
+
finally:
|
|
101
|
+
await _cleanup_process(process)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _validate_command_security(command: str) -> None:
|
|
105
|
+
"""
|
|
106
|
+
Validate command security using comprehensive validation from run_command.py.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
command: The command string to validate
|
|
110
|
+
|
|
111
|
+
Raises:
|
|
112
|
+
ModelRetry: If the command fails security validation
|
|
113
|
+
"""
|
|
114
|
+
if not command or not command.strip():
|
|
115
|
+
raise ModelRetry("Empty command not allowed")
|
|
116
|
+
|
|
117
|
+
# Always check for the most dangerous patterns regardless of shell features
|
|
118
|
+
for pattern in DANGEROUS_PATTERNS:
|
|
119
|
+
if re.search(pattern, command, re.IGNORECASE):
|
|
120
|
+
raise ModelRetry(f"Command contains dangerous pattern and is blocked: {pattern}")
|
|
121
|
+
|
|
122
|
+
# Check for dangerous injection patterns (more selective, before character checks)
|
|
123
|
+
strict_patterns = [
|
|
124
|
+
r";\s*rm\s+", # Command chaining to rm
|
|
125
|
+
r"&&\s*rm\s+", # Command chaining to rm
|
|
126
|
+
r"`[^`]*rm[^`]*`", # Command substitution with rm
|
|
127
|
+
r"\$\([^)]*rm[^)]*\)", # Command substitution with rm
|
|
128
|
+
r":\(\)\{.*\}\;", # Fork bomb
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
for pattern in strict_patterns:
|
|
132
|
+
if re.search(pattern, command):
|
|
133
|
+
raise ModelRetry(f"Potentially unsafe pattern detected in command: {pattern}")
|
|
134
|
+
|
|
135
|
+
# Check for restricted characters (but allow safe environment variable usage)
|
|
136
|
+
# Allow $ when used for legitimate environment variables or shell variables
|
|
137
|
+
if re.search(r"\$[^({a-zA-Z_]", command):
|
|
138
|
+
# $ followed by something that's not a valid variable start
|
|
139
|
+
raise ModelRetry("Potentially unsafe character '$' in command")
|
|
140
|
+
|
|
141
|
+
# Check other restricted characters but allow { } when part of valid variable expansion
|
|
142
|
+
if "{" in command or "}" in command: # noqa: SIM102
|
|
143
|
+
# Only block braces if they're not part of valid variable expansion
|
|
144
|
+
if not re.search(r"\$\{?\w+\}?", command):
|
|
145
|
+
for char in ["{", "}"]:
|
|
146
|
+
if char in command:
|
|
147
|
+
raise ModelRetry(f"Potentially unsafe character '{char}' in command")
|
|
148
|
+
|
|
149
|
+
# Check remaining restricted characters
|
|
150
|
+
for char in [";", "&", "`"]:
|
|
151
|
+
if char in command:
|
|
152
|
+
raise ModelRetry(f"Potentially unsafe character '{char}' in command")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _validate_inputs(command: str, cwd: str | None, timeout: int | None) -> None:
|
|
156
|
+
"""Validate command inputs."""
|
|
157
|
+
if timeout and (timeout < 1 or timeout > 300):
|
|
158
|
+
raise ModelRetry(
|
|
159
|
+
"Timeout must be between 1 and 300 seconds. "
|
|
160
|
+
"Use shorter timeouts for quick commands, longer for builds/tests."
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
if cwd and not os.path.isdir(cwd):
|
|
164
|
+
raise ModelRetry(
|
|
165
|
+
f"Working directory '{cwd}' does not exist. "
|
|
166
|
+
"Please verify the path or create the directory first."
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
if any(pattern in command for pattern in DESTRUCTIVE_PATTERNS):
|
|
170
|
+
raise ModelRetry(
|
|
171
|
+
f"Command contains potentially destructive operations: {command}\n"
|
|
172
|
+
"Please confirm this is intentional and safe for your system."
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _check_common_errors(command: str, returncode: int, stderr: str) -> None:
|
|
177
|
+
"""Check for common error patterns and provide guidance."""
|
|
178
|
+
if returncode == 0 or not stderr:
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
async def _cleanup_process(process) -> None:
|
|
183
|
+
"""Ensure process cleanup."""
|
|
184
|
+
if process is None or process.returncode is not None:
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
try:
|
|
189
|
+
process.terminate()
|
|
190
|
+
await asyncio.wait_for(process.wait(), timeout=5.0)
|
|
191
|
+
except TimeoutError:
|
|
192
|
+
process.kill()
|
|
193
|
+
await asyncio.wait_for(process.wait(), timeout=1.0)
|
|
194
|
+
except Exception:
|
|
195
|
+
pass
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _format_output(command: str, exit_code: int, stdout: str, stderr: str, cwd: str) -> str:
|
|
199
|
+
"""Format command output."""
|
|
200
|
+
lines = [
|
|
201
|
+
f"Command: {command}",
|
|
202
|
+
f"Exit Code: {exit_code}",
|
|
203
|
+
f"Working Directory: {cwd}",
|
|
204
|
+
"",
|
|
205
|
+
"STDOUT:",
|
|
206
|
+
stdout or "(no output)",
|
|
207
|
+
"",
|
|
208
|
+
"STDERR:",
|
|
209
|
+
stderr or "(no errors)",
|
|
210
|
+
]
|
|
211
|
+
|
|
212
|
+
result = "\n".join(lines)
|
|
213
|
+
|
|
214
|
+
if len(result) > MAX_COMMAND_OUTPUT:
|
|
215
|
+
start_part = result[:COMMAND_OUTPUT_START_INDEX]
|
|
216
|
+
if len(result) > COMMAND_OUTPUT_THRESHOLD:
|
|
217
|
+
end_part = result[-COMMAND_OUTPUT_END_SIZE:]
|
|
218
|
+
else:
|
|
219
|
+
end_part = result[COMMAND_OUTPUT_START_INDEX:]
|
|
220
|
+
result = start_part + CMD_OUTPUT_TRUNCATED + end_part
|
|
221
|
+
|
|
222
|
+
return result
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Tool decorators providing error handling.
|
|
2
|
+
|
|
3
|
+
This module provides decorators that wrap tool functions with:
|
|
4
|
+
- Consistent error handling (converts exceptions to ToolExecutionError)
|
|
5
|
+
- Logging of tool invocations
|
|
6
|
+
- File-specific error handling for file operations
|
|
7
|
+
- LSP diagnostic integration for file modifications
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import logging
|
|
12
|
+
from collections.abc import Callable, Coroutine
|
|
13
|
+
from functools import wraps
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, ParamSpec, TypeVar, overload
|
|
16
|
+
|
|
17
|
+
from pydantic_ai.exceptions import ModelRetry
|
|
18
|
+
|
|
19
|
+
from tunacode.configuration.defaults import DEFAULT_USER_CONFIG
|
|
20
|
+
from tunacode.exceptions import FileOperationError, ToolExecutionError
|
|
21
|
+
from tunacode.tools.xml_helper import load_prompt_from_xml
|
|
22
|
+
from tunacode.utils.config.user_configuration import load_config
|
|
23
|
+
|
|
24
|
+
P = ParamSpec("P")
|
|
25
|
+
R = TypeVar("R")
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
LSP_ORCHESTRATION_OVERHEAD_SECONDS = 1.0
|
|
30
|
+
LSP_DIAGNOSTICS_TIMEOUT_WARNING: str = "LSP diagnostics timed out for %s (no type errors shown)"
|
|
31
|
+
|
|
32
|
+
DEFAULT_LSP_CONFIG: dict[str, Any] = {
|
|
33
|
+
"enabled": False,
|
|
34
|
+
"timeout": 5.0,
|
|
35
|
+
"max_diagnostics": 20,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _merge_lsp_config(*configs: dict[str, Any]) -> dict[str, Any]:
|
|
40
|
+
merged: dict[str, Any] = {}
|
|
41
|
+
for config in configs:
|
|
42
|
+
merged.update(config)
|
|
43
|
+
return merged
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _get_lsp_config() -> dict[str, Any]:
|
|
47
|
+
"""Get LSP configuration from user config (merged with defaults)."""
|
|
48
|
+
default_settings = DEFAULT_USER_CONFIG.get("settings", {})
|
|
49
|
+
default_lsp_config = default_settings.get("lsp", {})
|
|
50
|
+
|
|
51
|
+
user_config = load_config() or {}
|
|
52
|
+
user_settings = user_config.get("settings", {})
|
|
53
|
+
user_lsp_config = user_settings.get("lsp", {})
|
|
54
|
+
|
|
55
|
+
return _merge_lsp_config(DEFAULT_LSP_CONFIG, default_lsp_config, user_lsp_config)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def _get_lsp_diagnostics(filepath: str) -> str:
|
|
59
|
+
"""Get LSP diagnostics for a file if LSP is enabled.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
filepath: Path to the file to check
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Formatted diagnostics string or empty string
|
|
66
|
+
"""
|
|
67
|
+
config = _get_lsp_config()
|
|
68
|
+
if not config.get("enabled", False):
|
|
69
|
+
return ""
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
from tunacode.lsp import format_diagnostics, get_diagnostics
|
|
73
|
+
|
|
74
|
+
timeout = config.get("timeout", 5.0)
|
|
75
|
+
diagnostics = await asyncio.wait_for(
|
|
76
|
+
get_diagnostics(Path(filepath), timeout=timeout),
|
|
77
|
+
timeout=timeout + LSP_ORCHESTRATION_OVERHEAD_SECONDS,
|
|
78
|
+
)
|
|
79
|
+
return format_diagnostics(diagnostics)
|
|
80
|
+
except TimeoutError:
|
|
81
|
+
logger.warning(LSP_DIAGNOSTICS_TIMEOUT_WARNING, filepath)
|
|
82
|
+
return ""
|
|
83
|
+
except Exception as e:
|
|
84
|
+
logger.debug("LSP diagnostics failed for %s: %s", filepath, e)
|
|
85
|
+
return ""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def base_tool(
|
|
89
|
+
func: Callable[P, Coroutine[Any, Any, R]],
|
|
90
|
+
) -> Callable[P, Coroutine[Any, Any, R]]:
|
|
91
|
+
"""Wrap tool with error handling.
|
|
92
|
+
|
|
93
|
+
Converts uncaught exceptions to ToolExecutionError while preserving
|
|
94
|
+
ModelRetry and ToolExecutionError pass-through.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
func: Async tool function to wrap
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
Wrapped function with error handling
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
@wraps(func)
|
|
104
|
+
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
105
|
+
try:
|
|
106
|
+
return await func(*args, **kwargs)
|
|
107
|
+
except ModelRetry:
|
|
108
|
+
raise
|
|
109
|
+
except ToolExecutionError:
|
|
110
|
+
raise
|
|
111
|
+
except FileOperationError:
|
|
112
|
+
raise
|
|
113
|
+
except Exception as e:
|
|
114
|
+
raise ToolExecutionError(
|
|
115
|
+
tool_name=func.__name__, message=str(e), original_error=e
|
|
116
|
+
) from e
|
|
117
|
+
|
|
118
|
+
xml_prompt = load_prompt_from_xml(func.__name__)
|
|
119
|
+
if xml_prompt:
|
|
120
|
+
wrapper.__doc__ = xml_prompt
|
|
121
|
+
|
|
122
|
+
return wrapper # type: ignore[return-value]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@overload
|
|
126
|
+
def file_tool(
|
|
127
|
+
func: Callable[..., Coroutine[Any, Any, str]],
|
|
128
|
+
) -> Callable[..., Coroutine[Any, Any, str]]: ...
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@overload
|
|
132
|
+
def file_tool(
|
|
133
|
+
*,
|
|
134
|
+
writes: bool = False,
|
|
135
|
+
) -> Callable[
|
|
136
|
+
[Callable[..., Coroutine[Any, Any, str]]],
|
|
137
|
+
Callable[..., Coroutine[Any, Any, str]],
|
|
138
|
+
]: ...
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def file_tool(
|
|
142
|
+
func: Callable[..., Coroutine[Any, Any, str]] | None = None,
|
|
143
|
+
*,
|
|
144
|
+
writes: bool = False,
|
|
145
|
+
) -> (
|
|
146
|
+
Callable[..., Coroutine[Any, Any, str]]
|
|
147
|
+
| Callable[
|
|
148
|
+
[Callable[..., Coroutine[Any, Any, str]]],
|
|
149
|
+
Callable[..., Coroutine[Any, Any, str]],
|
|
150
|
+
]
|
|
151
|
+
):
|
|
152
|
+
"""Wrap file tool with path-specific error handling and optional LSP diagnostics.
|
|
153
|
+
|
|
154
|
+
Provides specialized handling for common file operation errors:
|
|
155
|
+
- FileNotFoundError -> ModelRetry (allows LLM to correct path)
|
|
156
|
+
- PermissionError -> FileOperationError
|
|
157
|
+
- UnicodeDecodeError -> FileOperationError
|
|
158
|
+
- IOError/OSError -> FileOperationError
|
|
159
|
+
|
|
160
|
+
When writes=True, also fetches LSP diagnostics after successful file modification.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
func: Async file tool function to wrap. First argument must be filepath.
|
|
164
|
+
writes: If True, fetch LSP diagnostics after successful operation.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
Wrapped function with file-specific error handling
|
|
168
|
+
|
|
169
|
+
Usage:
|
|
170
|
+
@file_tool # Read-only, no LSP
|
|
171
|
+
async def read_file(filepath: str) -> str: ...
|
|
172
|
+
|
|
173
|
+
@file_tool(writes=True) # Write operation, LSP diagnostics enabled
|
|
174
|
+
async def update_file(filepath: str, ...) -> str: ...
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
def decorator(
|
|
178
|
+
fn: Callable[..., Coroutine[Any, Any, str]],
|
|
179
|
+
) -> Callable[..., Coroutine[Any, Any, str]]:
|
|
180
|
+
@wraps(fn)
|
|
181
|
+
async def wrapper(filepath: str, *args: Any, **kwargs: Any) -> str:
|
|
182
|
+
try:
|
|
183
|
+
result = await fn(filepath, *args, **kwargs)
|
|
184
|
+
|
|
185
|
+
# Add LSP diagnostics for write operations
|
|
186
|
+
if writes:
|
|
187
|
+
diagnostics_output = await _get_lsp_diagnostics(filepath)
|
|
188
|
+
if diagnostics_output:
|
|
189
|
+
diagnostics_first_result = f"{diagnostics_output}\n\n{result}"
|
|
190
|
+
result = diagnostics_first_result
|
|
191
|
+
|
|
192
|
+
return result
|
|
193
|
+
except FileNotFoundError as err:
|
|
194
|
+
raise ModelRetry(f"File not found: {filepath}. Check the path.") from err
|
|
195
|
+
except PermissionError as e:
|
|
196
|
+
raise FileOperationError(
|
|
197
|
+
operation="access", path=filepath, message=str(e), original_error=e
|
|
198
|
+
) from e
|
|
199
|
+
except UnicodeDecodeError as e:
|
|
200
|
+
raise FileOperationError(
|
|
201
|
+
operation="decode", path=filepath, message=str(e), original_error=e
|
|
202
|
+
) from e
|
|
203
|
+
except OSError as e:
|
|
204
|
+
raise FileOperationError(
|
|
205
|
+
operation="read/write", path=filepath, message=str(e), original_error=e
|
|
206
|
+
) from e
|
|
207
|
+
|
|
208
|
+
return base_tool(wrapper) # type: ignore[arg-type]
|
|
209
|
+
|
|
210
|
+
# Handle both @file_tool and @file_tool(writes=True)
|
|
211
|
+
if func is not None:
|
|
212
|
+
return decorator(func)
|
|
213
|
+
return decorator
|