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.

Files changed (174) hide show
  1. tunacode/__init__.py +0 -0
  2. tunacode/cli/textual_repl.tcss +283 -0
  3. tunacode/configuration/__init__.py +1 -0
  4. tunacode/configuration/defaults.py +45 -0
  5. tunacode/configuration/models.py +147 -0
  6. tunacode/configuration/models_registry.json +1 -0
  7. tunacode/configuration/pricing.py +74 -0
  8. tunacode/configuration/settings.py +35 -0
  9. tunacode/constants.py +227 -0
  10. tunacode/core/__init__.py +6 -0
  11. tunacode/core/agents/__init__.py +39 -0
  12. tunacode/core/agents/agent_components/__init__.py +48 -0
  13. tunacode/core/agents/agent_components/agent_config.py +441 -0
  14. tunacode/core/agents/agent_components/agent_helpers.py +290 -0
  15. tunacode/core/agents/agent_components/message_handler.py +99 -0
  16. tunacode/core/agents/agent_components/node_processor.py +477 -0
  17. tunacode/core/agents/agent_components/response_state.py +129 -0
  18. tunacode/core/agents/agent_components/result_wrapper.py +51 -0
  19. tunacode/core/agents/agent_components/state_transition.py +112 -0
  20. tunacode/core/agents/agent_components/streaming.py +271 -0
  21. tunacode/core/agents/agent_components/task_completion.py +40 -0
  22. tunacode/core/agents/agent_components/tool_buffer.py +44 -0
  23. tunacode/core/agents/agent_components/tool_executor.py +101 -0
  24. tunacode/core/agents/agent_components/truncation_checker.py +37 -0
  25. tunacode/core/agents/delegation_tools.py +109 -0
  26. tunacode/core/agents/main.py +545 -0
  27. tunacode/core/agents/prompts.py +66 -0
  28. tunacode/core/agents/research_agent.py +231 -0
  29. tunacode/core/compaction.py +218 -0
  30. tunacode/core/prompting/__init__.py +27 -0
  31. tunacode/core/prompting/loader.py +66 -0
  32. tunacode/core/prompting/prompting_engine.py +98 -0
  33. tunacode/core/prompting/sections.py +50 -0
  34. tunacode/core/prompting/templates.py +69 -0
  35. tunacode/core/state.py +409 -0
  36. tunacode/exceptions.py +313 -0
  37. tunacode/indexing/__init__.py +5 -0
  38. tunacode/indexing/code_index.py +432 -0
  39. tunacode/indexing/constants.py +86 -0
  40. tunacode/lsp/__init__.py +112 -0
  41. tunacode/lsp/client.py +351 -0
  42. tunacode/lsp/diagnostics.py +19 -0
  43. tunacode/lsp/servers.py +101 -0
  44. tunacode/prompts/default_prompt.md +952 -0
  45. tunacode/prompts/research/sections/agent_role.xml +5 -0
  46. tunacode/prompts/research/sections/constraints.xml +14 -0
  47. tunacode/prompts/research/sections/output_format.xml +57 -0
  48. tunacode/prompts/research/sections/tool_use.xml +23 -0
  49. tunacode/prompts/sections/advanced_patterns.xml +255 -0
  50. tunacode/prompts/sections/agent_role.xml +8 -0
  51. tunacode/prompts/sections/completion.xml +10 -0
  52. tunacode/prompts/sections/critical_rules.xml +37 -0
  53. tunacode/prompts/sections/examples.xml +220 -0
  54. tunacode/prompts/sections/output_style.xml +94 -0
  55. tunacode/prompts/sections/parallel_exec.xml +105 -0
  56. tunacode/prompts/sections/search_pattern.xml +100 -0
  57. tunacode/prompts/sections/system_info.xml +6 -0
  58. tunacode/prompts/sections/tool_use.xml +84 -0
  59. tunacode/prompts/sections/user_instructions.xml +3 -0
  60. tunacode/py.typed +0 -0
  61. tunacode/templates/__init__.py +5 -0
  62. tunacode/templates/loader.py +15 -0
  63. tunacode/tools/__init__.py +10 -0
  64. tunacode/tools/authorization/__init__.py +29 -0
  65. tunacode/tools/authorization/context.py +32 -0
  66. tunacode/tools/authorization/factory.py +20 -0
  67. tunacode/tools/authorization/handler.py +58 -0
  68. tunacode/tools/authorization/notifier.py +35 -0
  69. tunacode/tools/authorization/policy.py +19 -0
  70. tunacode/tools/authorization/requests.py +119 -0
  71. tunacode/tools/authorization/rules.py +72 -0
  72. tunacode/tools/bash.py +222 -0
  73. tunacode/tools/decorators.py +213 -0
  74. tunacode/tools/glob.py +353 -0
  75. tunacode/tools/grep.py +468 -0
  76. tunacode/tools/grep_components/__init__.py +9 -0
  77. tunacode/tools/grep_components/file_filter.py +93 -0
  78. tunacode/tools/grep_components/pattern_matcher.py +158 -0
  79. tunacode/tools/grep_components/result_formatter.py +87 -0
  80. tunacode/tools/grep_components/search_result.py +34 -0
  81. tunacode/tools/list_dir.py +205 -0
  82. tunacode/tools/prompts/bash_prompt.xml +10 -0
  83. tunacode/tools/prompts/glob_prompt.xml +7 -0
  84. tunacode/tools/prompts/grep_prompt.xml +10 -0
  85. tunacode/tools/prompts/list_dir_prompt.xml +7 -0
  86. tunacode/tools/prompts/read_file_prompt.xml +9 -0
  87. tunacode/tools/prompts/todoclear_prompt.xml +12 -0
  88. tunacode/tools/prompts/todoread_prompt.xml +16 -0
  89. tunacode/tools/prompts/todowrite_prompt.xml +28 -0
  90. tunacode/tools/prompts/update_file_prompt.xml +9 -0
  91. tunacode/tools/prompts/web_fetch_prompt.xml +11 -0
  92. tunacode/tools/prompts/write_file_prompt.xml +7 -0
  93. tunacode/tools/react.py +111 -0
  94. tunacode/tools/read_file.py +68 -0
  95. tunacode/tools/todo.py +222 -0
  96. tunacode/tools/update_file.py +62 -0
  97. tunacode/tools/utils/__init__.py +1 -0
  98. tunacode/tools/utils/ripgrep.py +311 -0
  99. tunacode/tools/utils/text_match.py +352 -0
  100. tunacode/tools/web_fetch.py +245 -0
  101. tunacode/tools/write_file.py +34 -0
  102. tunacode/tools/xml_helper.py +34 -0
  103. tunacode/types/__init__.py +166 -0
  104. tunacode/types/base.py +94 -0
  105. tunacode/types/callbacks.py +53 -0
  106. tunacode/types/dataclasses.py +121 -0
  107. tunacode/types/pydantic_ai.py +31 -0
  108. tunacode/types/state.py +122 -0
  109. tunacode/ui/__init__.py +6 -0
  110. tunacode/ui/app.py +542 -0
  111. tunacode/ui/commands/__init__.py +430 -0
  112. tunacode/ui/components/__init__.py +1 -0
  113. tunacode/ui/headless/__init__.py +5 -0
  114. tunacode/ui/headless/output.py +72 -0
  115. tunacode/ui/main.py +252 -0
  116. tunacode/ui/renderers/__init__.py +41 -0
  117. tunacode/ui/renderers/errors.py +197 -0
  118. tunacode/ui/renderers/panels.py +550 -0
  119. tunacode/ui/renderers/search.py +314 -0
  120. tunacode/ui/renderers/tools/__init__.py +21 -0
  121. tunacode/ui/renderers/tools/bash.py +247 -0
  122. tunacode/ui/renderers/tools/diagnostics.py +186 -0
  123. tunacode/ui/renderers/tools/glob.py +226 -0
  124. tunacode/ui/renderers/tools/grep.py +228 -0
  125. tunacode/ui/renderers/tools/list_dir.py +198 -0
  126. tunacode/ui/renderers/tools/read_file.py +226 -0
  127. tunacode/ui/renderers/tools/research.py +294 -0
  128. tunacode/ui/renderers/tools/update_file.py +237 -0
  129. tunacode/ui/renderers/tools/web_fetch.py +182 -0
  130. tunacode/ui/repl_support.py +226 -0
  131. tunacode/ui/screens/__init__.py +16 -0
  132. tunacode/ui/screens/model_picker.py +303 -0
  133. tunacode/ui/screens/session_picker.py +181 -0
  134. tunacode/ui/screens/setup.py +218 -0
  135. tunacode/ui/screens/theme_picker.py +90 -0
  136. tunacode/ui/screens/update_confirm.py +69 -0
  137. tunacode/ui/shell_runner.py +129 -0
  138. tunacode/ui/styles/layout.tcss +98 -0
  139. tunacode/ui/styles/modals.tcss +38 -0
  140. tunacode/ui/styles/panels.tcss +81 -0
  141. tunacode/ui/styles/theme-nextstep.tcss +303 -0
  142. tunacode/ui/styles/widgets.tcss +33 -0
  143. tunacode/ui/styles.py +18 -0
  144. tunacode/ui/widgets/__init__.py +23 -0
  145. tunacode/ui/widgets/command_autocomplete.py +62 -0
  146. tunacode/ui/widgets/editor.py +402 -0
  147. tunacode/ui/widgets/file_autocomplete.py +47 -0
  148. tunacode/ui/widgets/messages.py +46 -0
  149. tunacode/ui/widgets/resource_bar.py +182 -0
  150. tunacode/ui/widgets/status_bar.py +98 -0
  151. tunacode/utils/__init__.py +0 -0
  152. tunacode/utils/config/__init__.py +13 -0
  153. tunacode/utils/config/user_configuration.py +91 -0
  154. tunacode/utils/messaging/__init__.py +10 -0
  155. tunacode/utils/messaging/message_utils.py +34 -0
  156. tunacode/utils/messaging/token_counter.py +77 -0
  157. tunacode/utils/parsing/__init__.py +13 -0
  158. tunacode/utils/parsing/command_parser.py +55 -0
  159. tunacode/utils/parsing/json_utils.py +188 -0
  160. tunacode/utils/parsing/retry.py +146 -0
  161. tunacode/utils/parsing/tool_parser.py +267 -0
  162. tunacode/utils/security/__init__.py +15 -0
  163. tunacode/utils/security/command.py +106 -0
  164. tunacode/utils/system/__init__.py +25 -0
  165. tunacode/utils/system/gitignore.py +155 -0
  166. tunacode/utils/system/paths.py +190 -0
  167. tunacode/utils/ui/__init__.py +9 -0
  168. tunacode/utils/ui/file_filter.py +135 -0
  169. tunacode/utils/ui/helpers.py +24 -0
  170. tunacode_cli-0.1.21.dist-info/METADATA +170 -0
  171. tunacode_cli-0.1.21.dist-info/RECORD +174 -0
  172. tunacode_cli-0.1.21.dist-info/WHEEL +4 -0
  173. tunacode_cli-0.1.21.dist-info/entry_points.txt +2 -0
  174. 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