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.
- tunacode/cli/commands/__init__.py +2 -2
- tunacode/cli/commands/implementations/__init__.py +2 -3
- tunacode/cli/commands/implementations/command_reload.py +48 -0
- tunacode/cli/commands/implementations/debug.py +2 -2
- tunacode/cli/commands/implementations/development.py +10 -8
- tunacode/cli/commands/implementations/model.py +357 -29
- tunacode/cli/commands/implementations/quickstart.py +43 -0
- tunacode/cli/commands/implementations/system.py +96 -3
- tunacode/cli/commands/implementations/template.py +0 -2
- tunacode/cli/commands/registry.py +139 -5
- tunacode/cli/commands/slash/__init__.py +32 -0
- tunacode/cli/commands/slash/command.py +157 -0
- tunacode/cli/commands/slash/loader.py +135 -0
- tunacode/cli/commands/slash/processor.py +294 -0
- tunacode/cli/commands/slash/types.py +93 -0
- tunacode/cli/commands/slash/validator.py +400 -0
- tunacode/cli/main.py +23 -2
- tunacode/cli/repl.py +217 -190
- tunacode/cli/repl_components/command_parser.py +38 -4
- tunacode/cli/repl_components/error_recovery.py +85 -4
- tunacode/cli/repl_components/output_display.py +12 -1
- tunacode/cli/repl_components/tool_executor.py +1 -1
- tunacode/configuration/defaults.py +12 -3
- tunacode/configuration/key_descriptions.py +284 -0
- tunacode/configuration/settings.py +0 -1
- tunacode/constants.py +12 -40
- tunacode/core/agents/__init__.py +43 -2
- tunacode/core/agents/agent_components/__init__.py +7 -0
- tunacode/core/agents/agent_components/agent_config.py +249 -55
- tunacode/core/agents/agent_components/agent_helpers.py +43 -13
- tunacode/core/agents/agent_components/node_processor.py +179 -139
- tunacode/core/agents/agent_components/response_state.py +123 -6
- tunacode/core/agents/agent_components/state_transition.py +116 -0
- tunacode/core/agents/agent_components/streaming.py +296 -0
- tunacode/core/agents/agent_components/task_completion.py +19 -6
- tunacode/core/agents/agent_components/tool_buffer.py +21 -1
- tunacode/core/agents/agent_components/tool_executor.py +10 -0
- tunacode/core/agents/main.py +522 -370
- tunacode/core/agents/main_legact.py +538 -0
- tunacode/core/agents/prompts.py +66 -0
- tunacode/core/agents/utils.py +29 -121
- tunacode/core/code_index.py +83 -29
- tunacode/core/setup/__init__.py +0 -2
- tunacode/core/setup/config_setup.py +110 -20
- tunacode/core/setup/config_wizard.py +230 -0
- tunacode/core/setup/coordinator.py +14 -5
- tunacode/core/state.py +16 -20
- tunacode/core/token_usage/usage_tracker.py +5 -3
- tunacode/core/tool_authorization.py +352 -0
- tunacode/core/tool_handler.py +67 -40
- tunacode/exceptions.py +119 -5
- tunacode/prompts/system.xml +751 -0
- tunacode/services/mcp.py +125 -7
- tunacode/setup.py +5 -25
- tunacode/tools/base.py +163 -0
- tunacode/tools/bash.py +110 -1
- tunacode/tools/glob.py +332 -34
- tunacode/tools/grep.py +179 -82
- tunacode/tools/grep_components/result_formatter.py +98 -4
- tunacode/tools/list_dir.py +132 -2
- tunacode/tools/prompts/bash_prompt.xml +72 -0
- tunacode/tools/prompts/glob_prompt.xml +45 -0
- tunacode/tools/prompts/grep_prompt.xml +98 -0
- tunacode/tools/prompts/list_dir_prompt.xml +31 -0
- tunacode/tools/prompts/react_prompt.xml +23 -0
- tunacode/tools/prompts/read_file_prompt.xml +54 -0
- tunacode/tools/prompts/run_command_prompt.xml +64 -0
- tunacode/tools/prompts/update_file_prompt.xml +53 -0
- tunacode/tools/prompts/write_file_prompt.xml +37 -0
- tunacode/tools/react.py +153 -0
- tunacode/tools/read_file.py +91 -0
- tunacode/tools/run_command.py +114 -0
- tunacode/tools/schema_assembler.py +167 -0
- tunacode/tools/update_file.py +94 -0
- tunacode/tools/write_file.py +86 -0
- tunacode/tools/xml_helper.py +83 -0
- tunacode/tutorial/__init__.py +9 -0
- tunacode/tutorial/content.py +98 -0
- tunacode/tutorial/manager.py +182 -0
- tunacode/tutorial/steps.py +124 -0
- tunacode/types.py +20 -27
- tunacode/ui/completers.py +434 -50
- tunacode/ui/config_dashboard.py +585 -0
- tunacode/ui/console.py +63 -11
- tunacode/ui/input.py +20 -3
- tunacode/ui/keybindings.py +7 -4
- tunacode/ui/model_selector.py +395 -0
- tunacode/ui/output.py +40 -19
- tunacode/ui/panels.py +212 -43
- tunacode/ui/path_heuristics.py +91 -0
- tunacode/ui/prompt_manager.py +5 -1
- tunacode/ui/tool_ui.py +33 -10
- tunacode/utils/api_key_validation.py +93 -0
- tunacode/utils/config_comparator.py +340 -0
- tunacode/utils/json_utils.py +206 -0
- tunacode/utils/message_utils.py +14 -4
- tunacode/utils/models_registry.py +593 -0
- tunacode/utils/ripgrep.py +332 -9
- tunacode/utils/text_utils.py +18 -1
- tunacode/utils/user_configuration.py +45 -0
- tunacode_cli-0.0.78.6.dist-info/METADATA +260 -0
- tunacode_cli-0.0.78.6.dist-info/RECORD +158 -0
- {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.78.6.dist-info}/WHEEL +1 -2
- tunacode/cli/commands/implementations/todo.py +0 -217
- tunacode/context.py +0 -71
- tunacode/core/setup/git_safety_setup.py +0 -182
- tunacode/prompts/system.md +0 -731
- tunacode/tools/read_file_async_poc.py +0 -196
- tunacode/tools/todo.py +0 -349
- tunacode_cli-0.0.55.dist-info/METADATA +0 -322
- tunacode_cli-0.0.55.dist-info/RECORD +0 -126
- tunacode_cli-0.0.55.dist-info/top_level.txt +0 -1
- {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.78.6.dist-info}/entry_points.txt +0 -0
- {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
|
-
#
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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(
|
|
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
|
|
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,
|