tunacode-cli 0.0.55__py3-none-any.whl → 0.0.78.6__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 (114) hide show
  1. tunacode/cli/commands/__init__.py +2 -2
  2. tunacode/cli/commands/implementations/__init__.py +2 -3
  3. tunacode/cli/commands/implementations/command_reload.py +48 -0
  4. tunacode/cli/commands/implementations/debug.py +2 -2
  5. tunacode/cli/commands/implementations/development.py +10 -8
  6. tunacode/cli/commands/implementations/model.py +357 -29
  7. tunacode/cli/commands/implementations/quickstart.py +43 -0
  8. tunacode/cli/commands/implementations/system.py +96 -3
  9. tunacode/cli/commands/implementations/template.py +0 -2
  10. tunacode/cli/commands/registry.py +139 -5
  11. tunacode/cli/commands/slash/__init__.py +32 -0
  12. tunacode/cli/commands/slash/command.py +157 -0
  13. tunacode/cli/commands/slash/loader.py +135 -0
  14. tunacode/cli/commands/slash/processor.py +294 -0
  15. tunacode/cli/commands/slash/types.py +93 -0
  16. tunacode/cli/commands/slash/validator.py +400 -0
  17. tunacode/cli/main.py +23 -2
  18. tunacode/cli/repl.py +217 -190
  19. tunacode/cli/repl_components/command_parser.py +38 -4
  20. tunacode/cli/repl_components/error_recovery.py +85 -4
  21. tunacode/cli/repl_components/output_display.py +12 -1
  22. tunacode/cli/repl_components/tool_executor.py +1 -1
  23. tunacode/configuration/defaults.py +12 -3
  24. tunacode/configuration/key_descriptions.py +284 -0
  25. tunacode/configuration/settings.py +0 -1
  26. tunacode/constants.py +12 -40
  27. tunacode/core/agents/__init__.py +43 -2
  28. tunacode/core/agents/agent_components/__init__.py +7 -0
  29. tunacode/core/agents/agent_components/agent_config.py +249 -55
  30. tunacode/core/agents/agent_components/agent_helpers.py +43 -13
  31. tunacode/core/agents/agent_components/node_processor.py +179 -139
  32. tunacode/core/agents/agent_components/response_state.py +123 -6
  33. tunacode/core/agents/agent_components/state_transition.py +116 -0
  34. tunacode/core/agents/agent_components/streaming.py +296 -0
  35. tunacode/core/agents/agent_components/task_completion.py +19 -6
  36. tunacode/core/agents/agent_components/tool_buffer.py +21 -1
  37. tunacode/core/agents/agent_components/tool_executor.py +10 -0
  38. tunacode/core/agents/main.py +522 -370
  39. tunacode/core/agents/main_legact.py +538 -0
  40. tunacode/core/agents/prompts.py +66 -0
  41. tunacode/core/agents/utils.py +29 -121
  42. tunacode/core/code_index.py +83 -29
  43. tunacode/core/setup/__init__.py +0 -2
  44. tunacode/core/setup/config_setup.py +110 -20
  45. tunacode/core/setup/config_wizard.py +230 -0
  46. tunacode/core/setup/coordinator.py +14 -5
  47. tunacode/core/state.py +16 -20
  48. tunacode/core/token_usage/usage_tracker.py +5 -3
  49. tunacode/core/tool_authorization.py +352 -0
  50. tunacode/core/tool_handler.py +67 -40
  51. tunacode/exceptions.py +119 -5
  52. tunacode/prompts/system.xml +751 -0
  53. tunacode/services/mcp.py +125 -7
  54. tunacode/setup.py +5 -25
  55. tunacode/tools/base.py +163 -0
  56. tunacode/tools/bash.py +110 -1
  57. tunacode/tools/glob.py +332 -34
  58. tunacode/tools/grep.py +179 -82
  59. tunacode/tools/grep_components/result_formatter.py +98 -4
  60. tunacode/tools/list_dir.py +132 -2
  61. tunacode/tools/prompts/bash_prompt.xml +72 -0
  62. tunacode/tools/prompts/glob_prompt.xml +45 -0
  63. tunacode/tools/prompts/grep_prompt.xml +98 -0
  64. tunacode/tools/prompts/list_dir_prompt.xml +31 -0
  65. tunacode/tools/prompts/react_prompt.xml +23 -0
  66. tunacode/tools/prompts/read_file_prompt.xml +54 -0
  67. tunacode/tools/prompts/run_command_prompt.xml +64 -0
  68. tunacode/tools/prompts/update_file_prompt.xml +53 -0
  69. tunacode/tools/prompts/write_file_prompt.xml +37 -0
  70. tunacode/tools/react.py +153 -0
  71. tunacode/tools/read_file.py +91 -0
  72. tunacode/tools/run_command.py +114 -0
  73. tunacode/tools/schema_assembler.py +167 -0
  74. tunacode/tools/update_file.py +94 -0
  75. tunacode/tools/write_file.py +86 -0
  76. tunacode/tools/xml_helper.py +83 -0
  77. tunacode/tutorial/__init__.py +9 -0
  78. tunacode/tutorial/content.py +98 -0
  79. tunacode/tutorial/manager.py +182 -0
  80. tunacode/tutorial/steps.py +124 -0
  81. tunacode/types.py +20 -27
  82. tunacode/ui/completers.py +434 -50
  83. tunacode/ui/config_dashboard.py +585 -0
  84. tunacode/ui/console.py +63 -11
  85. tunacode/ui/input.py +20 -3
  86. tunacode/ui/keybindings.py +7 -4
  87. tunacode/ui/model_selector.py +395 -0
  88. tunacode/ui/output.py +40 -19
  89. tunacode/ui/panels.py +212 -43
  90. tunacode/ui/path_heuristics.py +91 -0
  91. tunacode/ui/prompt_manager.py +5 -1
  92. tunacode/ui/tool_ui.py +33 -10
  93. tunacode/utils/api_key_validation.py +93 -0
  94. tunacode/utils/config_comparator.py +340 -0
  95. tunacode/utils/json_utils.py +206 -0
  96. tunacode/utils/message_utils.py +14 -4
  97. tunacode/utils/models_registry.py +593 -0
  98. tunacode/utils/ripgrep.py +332 -9
  99. tunacode/utils/text_utils.py +18 -1
  100. tunacode/utils/user_configuration.py +45 -0
  101. tunacode_cli-0.0.78.6.dist-info/METADATA +260 -0
  102. tunacode_cli-0.0.78.6.dist-info/RECORD +158 -0
  103. {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.78.6.dist-info}/WHEEL +1 -2
  104. tunacode/cli/commands/implementations/todo.py +0 -217
  105. tunacode/context.py +0 -71
  106. tunacode/core/setup/git_safety_setup.py +0 -182
  107. tunacode/prompts/system.md +0 -731
  108. tunacode/tools/read_file_async_poc.py +0 -196
  109. tunacode/tools/todo.py +0 -349
  110. tunacode_cli-0.0.55.dist-info/METADATA +0 -322
  111. tunacode_cli-0.0.55.dist-info/RECORD +0 -126
  112. tunacode_cli-0.0.55.dist-info/top_level.txt +0 -1
  113. {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.78.6.dist-info}/entry_points.txt +0 -0
  114. {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.78.6.dist-info}/licenses/LICENSE +0 -0
tunacode/services/mcp.py CHANGED
@@ -5,9 +5,11 @@ Provides Model Context Protocol (MCP) server management functionality.
5
5
  Handles MCP server initialization, configuration validation, and client connections.
6
6
  """
7
7
 
8
+ import asyncio
9
+ import logging
8
10
  import os
9
11
  from contextlib import asynccontextmanager
10
- from typing import TYPE_CHECKING, AsyncIterator, List, Optional, Tuple
12
+ from typing import TYPE_CHECKING, AsyncIterator, Dict, List, Optional, Tuple
11
13
 
12
14
  from pydantic_ai.mcp import MCPServerStdio
13
15
 
@@ -16,9 +18,17 @@ from tunacode.types import MCPServers
16
18
 
17
19
  if TYPE_CHECKING:
18
20
  from mcp.client.stdio import ReadStream, WriteStream
21
+ from pydantic_ai import Agent
19
22
 
20
23
  from tunacode.core.state import StateManager
21
24
 
25
+ # Module-level cache for MCP server instances
26
+ _MCP_SERVER_CACHE: Dict[str, MCPServerStdio] = {}
27
+ _MCP_CONFIG_HASH: Optional[int] = None
28
+ _MCP_SERVER_AGENTS: Dict[str, "Agent"] = {}
29
+
30
+ logger = logging.getLogger(__name__)
31
+
22
32
 
23
33
  class QuietMCPServer(MCPServerStdio):
24
34
  """A version of ``MCPServerStdio`` that suppresses *all* output coming from the
@@ -54,28 +64,136 @@ class QuietMCPServer(MCPServerStdio):
54
64
  yield read_stream, write_stream
55
65
 
56
66
 
67
+ async def cleanup_mcp_servers(server_names: Optional[List[str]] = None) -> None:
68
+ """Clean up MCP server connections and clear cache.
69
+
70
+ Args:
71
+ server_names: Optional list of specific server names to clean up.
72
+ If None, all servers will be cleaned up.
73
+ """
74
+ global _MCP_SERVER_CACHE, _MCP_SERVER_AGENTS
75
+
76
+ servers_to_cleanup = server_names or list(_MCP_SERVER_CACHE.keys())
77
+
78
+ cleanup_tasks = []
79
+ for server_name in servers_to_cleanup:
80
+ if server_name in _MCP_SERVER_CACHE:
81
+ logger.debug(f"Cleaning up MCP server: {server_name}")
82
+ cleanup_tasks.append(_cleanup_single_server(server_name))
83
+
84
+ if cleanup_tasks:
85
+ try:
86
+ await asyncio.gather(*cleanup_tasks, return_exceptions=True)
87
+ except Exception as e:
88
+ logger.warning(f"Error during MCP server cleanup: {e}")
89
+
90
+ # Clear caches for cleaned up servers
91
+ if server_names is None:
92
+ # Clear all
93
+ _MCP_SERVER_CACHE.clear()
94
+ _MCP_SERVER_AGENTS.clear()
95
+ logger.debug("Cleared all MCP server caches")
96
+ else:
97
+ # Clear specific servers
98
+ for server_name in server_names:
99
+ _MCP_SERVER_CACHE.pop(server_name, None)
100
+ _MCP_SERVER_AGENTS.pop(server_name, None)
101
+ logger.debug(f"Cleared cache for MCP server: {server_name}")
102
+
103
+
104
+ async def _cleanup_single_server(server_name: str) -> None:
105
+ """Clean up a single MCP server instance.
106
+
107
+ Args:
108
+ server_name: Name of the server to clean up
109
+ """
110
+ if server_name not in _MCP_SERVER_CACHE:
111
+ return
112
+
113
+ server = _MCP_SERVER_CACHE[server_name]
114
+ agent = _MCP_SERVER_AGENTS.get(server_name)
115
+
116
+ try:
117
+ # Use agent's run_mcp_servers context manager if available
118
+ if agent and hasattr(agent, "run_mcp_servers"):
119
+ # The agent should handle proper cleanup via context manager exit
120
+ logger.debug(f"Agent cleanup for {server_name} handled by run_mcp_servers context")
121
+ else:
122
+ # Fallback: try to stop the server subprocess directly
123
+ if hasattr(server, "is_running") and server.is_running:
124
+ # MCPServerStdio doesn't expose direct cleanup, so we log this
125
+ logger.debug(f"MCP server {server_name} is running but no direct cleanup available")
126
+ except Exception as e:
127
+ logger.warning(f"Error cleaning up MCP server {server_name}: {e}")
128
+
129
+
130
+ def register_mcp_agent(server_name: str, agent: "Agent") -> None:
131
+ """Register an agent that manages MCP servers for cleanup tracking.
132
+
133
+ Args:
134
+ server_name: Name of the server configuration
135
+ agent: Agent instance that manages the server
136
+ """
137
+ _MCP_SERVER_AGENTS[server_name] = agent
138
+ logger.debug(f"Registered agent for MCP server: {server_name}")
139
+
140
+
57
141
  def get_mcp_servers(state_manager: "StateManager") -> List[MCPServerStdio]:
58
- """Load MCP servers from configuration.
142
+ """Load MCP servers from configuration with caching.
59
143
 
60
144
  Args:
61
145
  state_manager: The state manager containing user configuration
62
146
 
63
147
  Returns:
64
- List of MCP server instances
148
+ List of MCP server instances (cached when possible)
65
149
 
66
150
  Raises:
67
151
  MCPError: If a server configuration is invalid
68
152
  """
153
+ global _MCP_CONFIG_HASH
154
+
69
155
  mcp_servers: MCPServers = state_manager.session.user_config.get("mcpServers", {})
156
+
157
+ # Calculate hash of current config
158
+ current_hash = hash(str(mcp_servers))
159
+
160
+ # Check if config has changed
161
+ if _MCP_CONFIG_HASH == current_hash and _MCP_SERVER_CACHE:
162
+ # Return cached servers
163
+ return list(_MCP_SERVER_CACHE.values())
164
+
165
+ # Config changed or first load - cleanup old servers and rebuild cache
166
+ if _MCP_CONFIG_HASH is not None and _MCP_SERVER_CACHE:
167
+ # Config changed - schedule cleanup of stale servers
168
+ removed_servers = [name for name in _MCP_SERVER_CACHE.keys() if name not in mcp_servers]
169
+ if removed_servers:
170
+ logger.debug(
171
+ f"Configuration changed, cleaning up removed MCP servers: {removed_servers}"
172
+ )
173
+ # Schedule async cleanup - use asyncio.create_task to avoid blocking
174
+ try:
175
+ loop = asyncio.get_event_loop()
176
+ loop.create_task(cleanup_mcp_servers(removed_servers))
177
+ except RuntimeError:
178
+ # No event loop running, schedule for later
179
+ logger.debug("No event loop available, deferring MCP server cleanup")
180
+
181
+ _MCP_SERVER_CACHE.clear()
182
+ _MCP_CONFIG_HASH = current_hash
183
+
70
184
  loaded_servers: List[MCPServerStdio] = []
71
185
  MCPServerStdio.log_level = "critical"
72
186
 
73
187
  for server_name, conf in mcp_servers.items():
74
188
  try:
75
- # loaded_servers.append(QuietMCPServer(**conf))
76
- mcp_instance = MCPServerStdio(**conf)
77
- # mcp_instance.log_level = "critical"
78
- loaded_servers.append(mcp_instance)
189
+ # Check if this server is already cached
190
+ if server_name not in _MCP_SERVER_CACHE:
191
+ # Create new instance
192
+ mcp_instance = MCPServerStdio(**conf)
193
+ _MCP_SERVER_CACHE[server_name] = mcp_instance
194
+ logger.debug(f"Created MCP server instance: {server_name}")
195
+
196
+ loaded_servers.append(_MCP_SERVER_CACHE[server_name])
79
197
  except Exception as e:
80
198
  raise MCPError(
81
199
  server_name=server_name,
tunacode/setup.py CHANGED
@@ -5,20 +5,18 @@ Package setup and metadata configuration for the TunaCode CLI.
5
5
  Provides high-level setup functions for initializing the application and its agents.
6
6
  """
7
7
 
8
- from typing import Any, Optional
9
-
10
8
  from tunacode.core.setup import (
11
- AgentSetup,
12
9
  ConfigSetup,
13
10
  EnvironmentSetup,
14
- GitSafetySetup,
15
11
  SetupCoordinator,
16
12
  TemplateSetup,
17
13
  )
18
14
  from tunacode.core.state import StateManager
19
15
 
20
16
 
21
- async def setup(run_setup: bool, state_manager: StateManager, cli_config: dict = None) -> None:
17
+ async def setup(
18
+ run_setup: bool, state_manager: StateManager, cli_config: dict = None, wizard_mode: bool = False
19
+ ) -> None:
22
20
  """
23
21
  Setup TunaCode on startup using the new setup coordinator.
24
22
 
@@ -26,6 +24,7 @@ async def setup(run_setup: bool, state_manager: StateManager, cli_config: dict =
26
24
  run_setup (bool): If True, force run the setup process, resetting current config.
27
25
  state_manager (StateManager): The state manager instance.
28
26
  cli_config (dict): Optional CLI configuration with baseurl, model, and key.
27
+ wizard_mode (bool): If True, run interactive setup wizard.
29
28
  """
30
29
  coordinator = SetupCoordinator(state_manager)
31
30
 
@@ -36,25 +35,6 @@ async def setup(run_setup: bool, state_manager: StateManager, cli_config: dict =
36
35
  coordinator.register_step(config_setup)
37
36
  coordinator.register_step(EnvironmentSetup(state_manager))
38
37
  coordinator.register_step(TemplateSetup(state_manager))
39
- coordinator.register_step(GitSafetySetup(state_manager))
40
38
 
41
39
  # Run all setup steps
42
- await coordinator.run_setup(force_setup=run_setup)
43
-
44
-
45
- async def setup_agent(agent: Optional[Any], state_manager: StateManager) -> None:
46
- """
47
- Setup the agent separately.
48
-
49
- This is called from other parts of the codebase when an agent needs to be initialized.
50
-
51
- Args:
52
- agent: The agent instance to initialize.
53
- state_manager (StateManager): The state manager instance.
54
- """
55
- if agent is not None:
56
- agent_setup = AgentSetup(state_manager, agent)
57
- if await agent_setup.should_run():
58
- await agent_setup.execute()
59
- if not await agent_setup.validate():
60
- raise RuntimeError("Agent setup failed validation")
40
+ await coordinator.run_setup(force_setup=run_setup, wizard_mode=wizard_mode)
tunacode/tools/base.py CHANGED
@@ -4,7 +4,9 @@ This module provides a base class that implements common patterns
4
4
  for all tools including error handling, UI logging, and ModelRetry support.
5
5
  """
6
6
 
7
+ import asyncio
7
8
  from abc import ABC, abstractmethod
9
+ from typing import Any, Dict, List, Optional
8
10
 
9
11
  from pydantic_ai.exceptions import ModelRetry
10
12
 
@@ -24,6 +26,9 @@ class BaseTool(ABC):
24
26
  """
25
27
  self.ui = ui_logger
26
28
  self.logger = get_logger(self.__class__.__name__)
29
+ self._prompt_cache: Optional[str] = None
30
+ self._context: Dict[str, Any] = {}
31
+ self._resources: List[Any] = [] # Track resources for cleanup
27
32
 
28
33
  async def execute(self, *args, **kwargs) -> ToolResult:
29
34
  """Execute the tool with error handling and logging.
@@ -32,6 +37,7 @@ class BaseTool(ABC):
32
37
  - UI logging of the operation
33
38
  - Exception handling (except ModelRetry and ToolExecutionError)
34
39
  - Consistent error message formatting
40
+ - Resource cleanup on any exception
35
41
 
36
42
  Returns:
37
43
  str: Success message
@@ -59,6 +65,9 @@ class BaseTool(ABC):
59
65
  except Exception as e:
60
66
  # Handle any other exceptions
61
67
  await self._handle_error(e, *args, **kwargs)
68
+ finally:
69
+ # Ensure resource cleanup even on success or failure
70
+ await self.cleanup()
62
71
 
63
72
  @property
64
73
  @abstractmethod
@@ -81,6 +90,53 @@ class BaseTool(ABC):
81
90
  """
82
91
  pass
83
92
 
93
+ async def cleanup(self) -> None:
94
+ """Clean up any resources created during tool execution.
95
+
96
+ This method is called automatically in a finally block after tool execution.
97
+ Subclasses should override this to implement tool-specific cleanup logic.
98
+
99
+ The base implementation handles cleanup of any resources registered via
100
+ register_resource(), attempting to call close() or cleanup() methods.
101
+ """
102
+ for resource in self._resources:
103
+ try:
104
+ if hasattr(resource, "close"):
105
+ if asyncio.iscoroutinefunction(resource.close):
106
+ await resource.close()
107
+ else:
108
+ resource.close()
109
+ elif hasattr(resource, "cleanup"):
110
+ if asyncio.iscoroutinefunction(resource.cleanup):
111
+ await resource.cleanup()
112
+ else:
113
+ resource.cleanup()
114
+ except Exception as e:
115
+ self.logger.warning(f"Failed to clean up resource {resource}: {e}")
116
+
117
+ # Clear the resource list
118
+ self._resources.clear()
119
+
120
+ def register_resource(self, resource: Any) -> None:
121
+ """Register a resource for automatic cleanup.
122
+
123
+ Resources registered here will be automatically cleaned up in the finally
124
+ block of execute(), regardless of success or failure.
125
+
126
+ Args:
127
+ resource: Any object with a close() or cleanup() method
128
+ """
129
+ self._resources.append(resource)
130
+
131
+ async def __aenter__(self):
132
+ """Enter async context manager."""
133
+ return self
134
+
135
+ async def __aexit__(self, exc_type, _, __):
136
+ """Exit async context manager and cleanup resources."""
137
+ await self.cleanup()
138
+ return False
139
+
84
140
  async def _handle_error(self, error: Exception, *args, **kwargs) -> ToolResult:
85
141
  """Handle errors by logging and raising proper exceptions.
86
142
 
@@ -138,6 +194,113 @@ class BaseTool(ABC):
138
194
  """
139
195
  return f"in {self.tool_name}"
140
196
 
197
+ def prompt(self, context: Optional[Dict[str, Any]] = None) -> str:
198
+ """Generate the prompt for this tool.
199
+
200
+ Args:
201
+ context: Optional context including model, permissions, environment
202
+
203
+ Returns:
204
+ str: The generated prompt for this tool
205
+ """
206
+ # Update context if provided
207
+ if context:
208
+ self._context.update(context)
209
+
210
+ # Check cache if context hasn't changed
211
+ cache_key = str(sorted(self._context.items()))
212
+ if self._prompt_cache and cache_key == getattr(self, "_cache_key", None):
213
+ return self._prompt_cache
214
+
215
+ # Generate new prompt
216
+ prompt = self._generate_prompt()
217
+
218
+ # Cache the result
219
+ self._prompt_cache = prompt
220
+ self._cache_key = cache_key
221
+
222
+ return prompt
223
+
224
+ def _generate_prompt(self) -> str:
225
+ """Generate the actual prompt based on current context.
226
+
227
+ Override this method in subclasses to provide tool-specific prompts.
228
+
229
+ Returns:
230
+ str: The generated prompt
231
+ """
232
+ # Default prompt generation
233
+ base_prompt = self._get_base_prompt()
234
+
235
+ # Apply model-specific adjustments
236
+ if "model" in self._context:
237
+ base_prompt = self._adjust_for_model(base_prompt, self._context["model"])
238
+
239
+ # Apply permission-specific adjustments
240
+ if "permissions" in self._context:
241
+ base_prompt = self._adjust_for_permissions(base_prompt, self._context["permissions"])
242
+
243
+ return base_prompt
244
+
245
+ def _get_base_prompt(self) -> str:
246
+ """Get the base prompt for this tool.
247
+
248
+ Override this in subclasses to provide tool-specific base prompts.
249
+
250
+ Returns:
251
+ str: The base prompt template
252
+ """
253
+ return f"Execute the {self.tool_name} tool to perform its designated operation."
254
+
255
+ def _adjust_for_model(self, prompt: str, model: str) -> str:
256
+ """Adjust prompt based on the model being used.
257
+
258
+ Args:
259
+ prompt: The base prompt
260
+ model: The model identifier
261
+
262
+ Returns:
263
+ str: Adjusted prompt
264
+ """
265
+ # Default implementation - override in subclasses for specific adjustments
266
+ return prompt
267
+
268
+ def _adjust_for_permissions(self, prompt: str, permissions: Dict[str, Any]) -> str:
269
+ """Adjust prompt based on permissions.
270
+
271
+ Args:
272
+ prompt: The base prompt
273
+ permissions: Permission settings
274
+
275
+ Returns:
276
+ str: Adjusted prompt
277
+ """
278
+ # Default implementation - override in subclasses for specific adjustments
279
+ return prompt
280
+
281
+ def get_tool_schema(self) -> Dict[str, Any]:
282
+ """Generate the tool schema for API integration.
283
+
284
+ Returns:
285
+ Dict containing the tool schema in OpenAI function format
286
+ """
287
+ return {
288
+ "name": self.tool_name,
289
+ "description": self.prompt(),
290
+ "parameters": self._get_parameters_schema(),
291
+ }
292
+
293
+ @abstractmethod
294
+ def _get_parameters_schema(self) -> Dict[str, Any]:
295
+ """Get the parameters schema for this tool.
296
+
297
+ Must be implemented by subclasses.
298
+
299
+ Returns:
300
+ Dict containing the JSON schema for tool parameters
301
+ """
302
+ pass
303
+
141
304
 
142
305
  class FileBasedTool(BaseTool):
143
306
  """Base class for tools that work with files.
tunacode/tools/bash.py CHANGED
@@ -7,10 +7,14 @@ environment variables, timeouts, and improved output handling.
7
7
  """
8
8
 
9
9
  import asyncio
10
+ import logging
10
11
  import os
11
12
  import subprocess
12
- from typing import Dict, Optional
13
+ from functools import lru_cache
14
+ from pathlib import Path
15
+ from typing import Any, Dict, List, Optional
13
16
 
17
+ import defusedxml.ElementTree as ET
14
18
  from pydantic_ai.exceptions import ModelRetry
15
19
 
16
20
  from tunacode.constants import MAX_COMMAND_OUTPUT
@@ -18,6 +22,8 @@ from tunacode.exceptions import ToolExecutionError
18
22
  from tunacode.tools.base import BaseTool
19
23
  from tunacode.types import ToolResult
20
24
 
25
+ logger = logging.getLogger(__name__)
26
+
21
27
 
22
28
  class BashTool(BaseTool):
23
29
  """Enhanced shell command execution tool with advanced features."""
@@ -26,6 +32,95 @@ class BashTool(BaseTool):
26
32
  def tool_name(self) -> str:
27
33
  return "Bash"
28
34
 
35
+ @lru_cache(maxsize=1)
36
+ def _get_base_prompt(self) -> str:
37
+ """Load and return the base prompt from XML file.
38
+
39
+ Returns:
40
+ str: The loaded prompt from XML or a default prompt
41
+ """
42
+ try:
43
+ # Load prompt from XML file
44
+ prompt_file = Path(__file__).parent / "prompts" / "bash_prompt.xml"
45
+ if prompt_file.exists():
46
+ tree = ET.parse(prompt_file)
47
+ root = tree.getroot()
48
+ description = root.find("description")
49
+ if description is not None:
50
+ return description.text.strip()
51
+ except Exception as e:
52
+ logger.warning(f"Failed to load XML prompt for bash: {e}")
53
+
54
+ # Fallback to default prompt
55
+ return (
56
+ """Executes a given bash command in a persistent shell session with optional timeout"""
57
+ )
58
+
59
+ @lru_cache(maxsize=1)
60
+ def _get_parameters_schema(self) -> Dict[str, Any]:
61
+ """Get the parameters schema for bash tool.
62
+
63
+ Returns:
64
+ Dict containing the JSON schema for tool parameters
65
+ """
66
+ # Try to load from XML first
67
+ try:
68
+ prompt_file = Path(__file__).parent / "prompts" / "bash_prompt.xml"
69
+ if prompt_file.exists():
70
+ tree = ET.parse(prompt_file)
71
+ root = tree.getroot()
72
+ parameters = root.find("parameters")
73
+ if parameters is not None:
74
+ schema: Dict[str, Any] = {"type": "object", "properties": {}, "required": []}
75
+ required_fields: List[str] = []
76
+
77
+ for param in parameters.findall("parameter"):
78
+ name = param.get("name")
79
+ required = param.get("required", "false").lower() == "true"
80
+ param_type = param.find("type")
81
+ description = param.find("description")
82
+
83
+ if name and param_type is not None:
84
+ prop = {
85
+ "type": param_type.text.strip(),
86
+ "description": description.text.strip()
87
+ if description is not None
88
+ else "",
89
+ }
90
+
91
+ schema["properties"][name] = prop
92
+ if required:
93
+ required_fields.append(name)
94
+
95
+ schema["required"] = required_fields
96
+ return schema
97
+ except Exception as e:
98
+ logger.warning(f"Failed to load parameters from XML for bash: {e}")
99
+
100
+ # Fallback to hardcoded schema
101
+ return {
102
+ "type": "object",
103
+ "properties": {
104
+ "command": {
105
+ "type": "string",
106
+ "description": "The command to execute",
107
+ },
108
+ "description": {
109
+ "type": "string",
110
+ "description": "Clear, concise description of what this command does",
111
+ },
112
+ "timeout": {
113
+ "type": "number",
114
+ "description": "Optional timeout in milliseconds",
115
+ },
116
+ "run_in_background": {
117
+ "type": "boolean",
118
+ "description": "Set to true to run this command in the background",
119
+ },
120
+ },
121
+ "required": ["command"],
122
+ }
123
+
29
124
  async def _execute(
30
125
  self,
31
126
  command: str,
@@ -83,6 +178,7 @@ class BashTool(BaseTool):
83
178
  # Set working directory
84
179
  exec_cwd = cwd or os.getcwd()
85
180
 
181
+ process = None
86
182
  try:
87
183
  # Execute command with timeout
88
184
  process = await asyncio.create_subprocess_shell(
@@ -143,6 +239,19 @@ class BashTool(BaseTool):
143
239
  f"Shell not found. Cannot execute command: {command}\n"
144
240
  "This typically indicates a system configuration issue."
145
241
  )
242
+ finally:
243
+ # Ensure process cleanup regardless of success or failure
244
+ if process is not None and process.returncode is None:
245
+ try:
246
+ # Multi-stage escalation: graceful → terminate → kill
247
+ try:
248
+ process.terminate()
249
+ await asyncio.wait_for(process.wait(), timeout=5.0)
250
+ except asyncio.TimeoutError:
251
+ process.kill()
252
+ await asyncio.wait_for(process.wait(), timeout=1.0)
253
+ except Exception as cleanup_error:
254
+ self.logger.warning(f"Failed to cleanup process: {cleanup_error}")
146
255
 
147
256
  def _format_output(
148
257
  self,