tunacode-cli 0.0.70__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 +0 -2
- tunacode/cli/commands/implementations/__init__.py +0 -3
- 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/system.py +3 -2
- tunacode/cli/commands/implementations/template.py +0 -2
- tunacode/cli/commands/registry.py +8 -7
- tunacode/cli/commands/slash/loader.py +2 -1
- tunacode/cli/commands/slash/validator.py +2 -1
- tunacode/cli/main.py +19 -1
- tunacode/cli/repl.py +90 -229
- tunacode/cli/repl_components/command_parser.py +2 -1
- tunacode/cli/repl_components/error_recovery.py +8 -5
- tunacode/cli/repl_components/output_display.py +1 -10
- tunacode/cli/repl_components/tool_executor.py +1 -13
- tunacode/configuration/defaults.py +2 -2
- tunacode/configuration/key_descriptions.py +284 -0
- tunacode/configuration/settings.py +0 -1
- tunacode/constants.py +6 -42
- tunacode/core/agents/__init__.py +43 -2
- tunacode/core/agents/agent_components/__init__.py +7 -0
- tunacode/core/agents/agent_components/agent_config.py +162 -158
- tunacode/core/agents/agent_components/agent_helpers.py +31 -2
- tunacode/core/agents/agent_components/node_processor.py +180 -146
- 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 -122
- tunacode/core/setup/__init__.py +0 -2
- tunacode/core/setup/config_setup.py +88 -227
- tunacode/core/setup/config_wizard.py +230 -0
- tunacode/core/setup/coordinator.py +2 -1
- tunacode/core/state.py +16 -64
- tunacode/core/token_usage/usage_tracker.py +3 -1
- tunacode/core/tool_authorization.py +352 -0
- tunacode/core/tool_handler.py +67 -60
- tunacode/prompts/system.xml +751 -0
- tunacode/services/mcp.py +97 -1
- tunacode/setup.py +0 -23
- tunacode/tools/base.py +54 -1
- tunacode/tools/bash.py +14 -0
- tunacode/tools/glob.py +4 -2
- tunacode/tools/grep.py +7 -17
- tunacode/tools/prompts/glob_prompt.xml +1 -1
- tunacode/tools/prompts/grep_prompt.xml +1 -0
- tunacode/tools/prompts/list_dir_prompt.xml +1 -1
- tunacode/tools/prompts/react_prompt.xml +23 -0
- tunacode/tools/prompts/read_file_prompt.xml +1 -1
- tunacode/tools/react.py +153 -0
- tunacode/tools/run_command.py +15 -0
- tunacode/types.py +14 -79
- tunacode/ui/completers.py +434 -50
- tunacode/ui/config_dashboard.py +585 -0
- tunacode/ui/console.py +63 -11
- tunacode/ui/input.py +8 -3
- tunacode/ui/keybindings.py +0 -18
- tunacode/ui/model_selector.py +395 -0
- tunacode/ui/output.py +40 -19
- tunacode/ui/panels.py +173 -49
- tunacode/ui/path_heuristics.py +91 -0
- tunacode/ui/prompt_manager.py +1 -20
- tunacode/ui/tool_ui.py +30 -8
- tunacode/utils/api_key_validation.py +93 -0
- tunacode/utils/config_comparator.py +340 -0
- tunacode/utils/models_registry.py +593 -0
- tunacode/utils/text_utils.py +18 -1
- {tunacode_cli-0.0.70.dist-info → tunacode_cli-0.0.78.6.dist-info}/METADATA +80 -12
- {tunacode_cli-0.0.70.dist-info → tunacode_cli-0.0.78.6.dist-info}/RECORD +78 -74
- tunacode/cli/commands/implementations/plan.py +0 -50
- tunacode/cli/commands/implementations/todo.py +0 -217
- tunacode/context.py +0 -71
- tunacode/core/setup/git_safety_setup.py +0 -186
- tunacode/prompts/system.md +0 -359
- tunacode/prompts/system.md.bak +0 -487
- tunacode/tools/exit_plan_mode.py +0 -273
- tunacode/tools/present_plan.py +0 -288
- tunacode/tools/prompts/exit_plan_mode_prompt.xml +0 -25
- tunacode/tools/prompts/present_plan_prompt.xml +0 -20
- tunacode/tools/prompts/todo_prompt.xml +0 -96
- tunacode/tools/todo.py +0 -456
- {tunacode_cli-0.0.70.dist-info → tunacode_cli-0.0.78.6.dist-info}/WHEEL +0 -0
- {tunacode_cli-0.0.70.dist-info → tunacode_cli-0.0.78.6.dist-info}/entry_points.txt +0 -0
- {tunacode_cli-0.0.70.dist-info → tunacode_cli-0.0.78.6.dist-info}/licenses/LICENSE +0 -0
tunacode/services/mcp.py
CHANGED
|
@@ -5,6 +5,8 @@ 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
12
|
from typing import TYPE_CHECKING, AsyncIterator, Dict, List, Optional, Tuple
|
|
@@ -16,12 +18,16 @@ 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
|
|
|
22
25
|
# Module-level cache for MCP server instances
|
|
23
26
|
_MCP_SERVER_CACHE: Dict[str, MCPServerStdio] = {}
|
|
24
27
|
_MCP_CONFIG_HASH: Optional[int] = None
|
|
28
|
+
_MCP_SERVER_AGENTS: Dict[str, "Agent"] = {}
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
25
31
|
|
|
26
32
|
|
|
27
33
|
class QuietMCPServer(MCPServerStdio):
|
|
@@ -58,6 +64,80 @@ class QuietMCPServer(MCPServerStdio):
|
|
|
58
64
|
yield read_stream, write_stream
|
|
59
65
|
|
|
60
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
|
+
|
|
61
141
|
def get_mcp_servers(state_manager: "StateManager") -> List[MCPServerStdio]:
|
|
62
142
|
"""Load MCP servers from configuration with caching.
|
|
63
143
|
|
|
@@ -82,7 +162,22 @@ def get_mcp_servers(state_manager: "StateManager") -> List[MCPServerStdio]:
|
|
|
82
162
|
# Return cached servers
|
|
83
163
|
return list(_MCP_SERVER_CACHE.values())
|
|
84
164
|
|
|
85
|
-
# Config changed or first load -
|
|
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
|
+
|
|
86
181
|
_MCP_SERVER_CACHE.clear()
|
|
87
182
|
_MCP_CONFIG_HASH = current_hash
|
|
88
183
|
|
|
@@ -96,6 +191,7 @@ def get_mcp_servers(state_manager: "StateManager") -> List[MCPServerStdio]:
|
|
|
96
191
|
# Create new instance
|
|
97
192
|
mcp_instance = MCPServerStdio(**conf)
|
|
98
193
|
_MCP_SERVER_CACHE[server_name] = mcp_instance
|
|
194
|
+
logger.debug(f"Created MCP server instance: {server_name}")
|
|
99
195
|
|
|
100
196
|
loaded_servers.append(_MCP_SERVER_CACHE[server_name])
|
|
101
197
|
except Exception as e:
|
tunacode/setup.py
CHANGED
|
@@ -5,13 +5,9 @@ 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
|
)
|
|
@@ -39,25 +35,6 @@ async def setup(
|
|
|
39
35
|
coordinator.register_step(config_setup)
|
|
40
36
|
coordinator.register_step(EnvironmentSetup(state_manager))
|
|
41
37
|
coordinator.register_step(TemplateSetup(state_manager))
|
|
42
|
-
coordinator.register_step(GitSafetySetup(state_manager))
|
|
43
38
|
|
|
44
39
|
# Run all setup steps
|
|
45
40
|
await coordinator.run_setup(force_setup=run_setup, wizard_mode=wizard_mode)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
async def setup_agent(agent: Optional[Any], state_manager: StateManager) -> None:
|
|
49
|
-
"""
|
|
50
|
-
Setup the agent separately.
|
|
51
|
-
|
|
52
|
-
This is called from other parts of the codebase when an agent needs to be initialized.
|
|
53
|
-
|
|
54
|
-
Args:
|
|
55
|
-
agent: The agent instance to initialize.
|
|
56
|
-
state_manager (StateManager): The state manager instance.
|
|
57
|
-
"""
|
|
58
|
-
if agent is not None:
|
|
59
|
-
agent_setup = AgentSetup(state_manager, agent)
|
|
60
|
-
if await agent_setup.should_run():
|
|
61
|
-
await agent_setup.execute()
|
|
62
|
-
if not await agent_setup.validate():
|
|
63
|
-
raise RuntimeError("Agent setup failed validation")
|
tunacode/tools/base.py
CHANGED
|
@@ -4,8 +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
|
|
8
|
-
from typing import Any, Dict, Optional
|
|
9
|
+
from typing import Any, Dict, List, Optional
|
|
9
10
|
|
|
10
11
|
from pydantic_ai.exceptions import ModelRetry
|
|
11
12
|
|
|
@@ -27,6 +28,7 @@ class BaseTool(ABC):
|
|
|
27
28
|
self.logger = get_logger(self.__class__.__name__)
|
|
28
29
|
self._prompt_cache: Optional[str] = None
|
|
29
30
|
self._context: Dict[str, Any] = {}
|
|
31
|
+
self._resources: List[Any] = [] # Track resources for cleanup
|
|
30
32
|
|
|
31
33
|
async def execute(self, *args, **kwargs) -> ToolResult:
|
|
32
34
|
"""Execute the tool with error handling and logging.
|
|
@@ -35,6 +37,7 @@ class BaseTool(ABC):
|
|
|
35
37
|
- UI logging of the operation
|
|
36
38
|
- Exception handling (except ModelRetry and ToolExecutionError)
|
|
37
39
|
- Consistent error message formatting
|
|
40
|
+
- Resource cleanup on any exception
|
|
38
41
|
|
|
39
42
|
Returns:
|
|
40
43
|
str: Success message
|
|
@@ -62,6 +65,9 @@ class BaseTool(ABC):
|
|
|
62
65
|
except Exception as e:
|
|
63
66
|
# Handle any other exceptions
|
|
64
67
|
await self._handle_error(e, *args, **kwargs)
|
|
68
|
+
finally:
|
|
69
|
+
# Ensure resource cleanup even on success or failure
|
|
70
|
+
await self.cleanup()
|
|
65
71
|
|
|
66
72
|
@property
|
|
67
73
|
@abstractmethod
|
|
@@ -84,6 +90,53 @@ class BaseTool(ABC):
|
|
|
84
90
|
"""
|
|
85
91
|
pass
|
|
86
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
|
+
|
|
87
140
|
async def _handle_error(self, error: Exception, *args, **kwargs) -> ToolResult:
|
|
88
141
|
"""Handle errors by logging and raising proper exceptions.
|
|
89
142
|
|
tunacode/tools/bash.py
CHANGED
|
@@ -178,6 +178,7 @@ class BashTool(BaseTool):
|
|
|
178
178
|
# Set working directory
|
|
179
179
|
exec_cwd = cwd or os.getcwd()
|
|
180
180
|
|
|
181
|
+
process = None
|
|
181
182
|
try:
|
|
182
183
|
# Execute command with timeout
|
|
183
184
|
process = await asyncio.create_subprocess_shell(
|
|
@@ -238,6 +239,19 @@ class BashTool(BaseTool):
|
|
|
238
239
|
f"Shell not found. Cannot execute command: {command}\n"
|
|
239
240
|
"This typically indicates a system configuration issue."
|
|
240
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}")
|
|
241
255
|
|
|
242
256
|
def _format_output(
|
|
243
257
|
self,
|
tunacode/tools/glob.py
CHANGED
|
@@ -553,9 +553,11 @@ async def glob(
|
|
|
553
553
|
directory: Directory to search in (default: current directory)
|
|
554
554
|
recursive: Whether to search recursively (default: True)
|
|
555
555
|
include_hidden: Whether to include hidden files/directories (default: False)
|
|
556
|
-
exclude_dirs: Additional directories to exclude from search
|
|
556
|
+
exclude_dirs: Additional directories to exclude from search
|
|
557
|
+
(default: common build/cache dirs)
|
|
557
558
|
max_results: Maximum number of results to return (default: 5000)
|
|
558
|
-
sort_by: How to sort results - "modified", "size", "alphabetical", or "depth"
|
|
559
|
+
sort_by: How to sort results - "modified", "size", "alphabetical", or "depth"
|
|
560
|
+
(default: "modified")
|
|
559
561
|
case_sensitive: Whether pattern matching is case-sensitive (default: False)
|
|
560
562
|
use_gitignore: Whether to respect .gitignore patterns (default: True)
|
|
561
563
|
|
tunacode/tools/grep.py
CHANGED
|
@@ -242,7 +242,10 @@ Usage:
|
|
|
242
242
|
raise ToolExecutionError(f"Unknown search type: {search_type}")
|
|
243
243
|
|
|
244
244
|
# 5️⃣ Format and return results with strategy info
|
|
245
|
-
strategy_info =
|
|
245
|
+
strategy_info = (
|
|
246
|
+
f"Strategy: {search_type} (was {original_search_type}), "
|
|
247
|
+
f"Files: {len(candidates)}/{5000}"
|
|
248
|
+
)
|
|
246
249
|
formatted_results = self._result_formatter.format_results(results, pattern, config)
|
|
247
250
|
|
|
248
251
|
if return_format == "list":
|
|
@@ -281,7 +284,6 @@ Usage:
|
|
|
281
284
|
def run_enhanced_ripgrep():
|
|
282
285
|
"""Execute ripgrep search using the new executor."""
|
|
283
286
|
start_time = time.time()
|
|
284
|
-
first_match_time = None
|
|
285
287
|
results = []
|
|
286
288
|
|
|
287
289
|
# Configure timeout from settings
|
|
@@ -306,17 +308,8 @@ Usage:
|
|
|
306
308
|
context_after=config.context_lines,
|
|
307
309
|
)
|
|
308
310
|
|
|
309
|
-
#
|
|
310
|
-
|
|
311
|
-
first_match_time = time.time() - start_time
|
|
312
|
-
|
|
313
|
-
# Check if we exceeded the first match deadline
|
|
314
|
-
if first_match_time > config.first_match_deadline:
|
|
315
|
-
if self._config.get("debug", False):
|
|
316
|
-
logger.debug(
|
|
317
|
-
f"Search exceeded first match deadline: {first_match_time:.2f}s"
|
|
318
|
-
)
|
|
319
|
-
raise TooBroadPatternError(pattern, config.first_match_deadline)
|
|
311
|
+
# Ripgrep doesn't provide timing info for first match, so we rely on
|
|
312
|
+
# the overall timeout mechanism instead of first_match_deadline
|
|
320
313
|
|
|
321
314
|
# Parse results
|
|
322
315
|
for result_line in search_results:
|
|
@@ -363,10 +356,7 @@ Usage:
|
|
|
363
356
|
)
|
|
364
357
|
|
|
365
358
|
if self._config.get("debug", False):
|
|
366
|
-
logger.debug(
|
|
367
|
-
f"Ripgrep search completed in {total_time:.2f}s "
|
|
368
|
-
f"(first match: {first_match_time:.2f}s if found)"
|
|
369
|
-
)
|
|
359
|
+
logger.debug(f"Ripgrep search completed in {total_time:.2f}s")
|
|
370
360
|
|
|
371
361
|
return results
|
|
372
362
|
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
- Returns matching file paths sorted by modification time
|
|
7
7
|
- Use this tool when you need to find files by name patterns
|
|
8
8
|
- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Agent tool instead
|
|
9
|
-
- You have the capability to call multiple tools in a single response.
|
|
9
|
+
- You have the capability to call multiple tools in a single response. When you need multiple glob patterns, list each call up front so the read-only scheduler can execute them together as one batch.
|
|
10
10
|
</description>
|
|
11
11
|
|
|
12
12
|
<parameters>
|
|
@@ -11,6 +11,7 @@ A powerful search tool built on ripgrep
|
|
|
11
11
|
- Use Task tool for open-ended searches requiring multiple rounds
|
|
12
12
|
- Pattern syntax: Uses ripgrep (not grep) - literal braces need escaping (use `interface\{\}` to find `interface{}` in Go code)
|
|
13
13
|
- Multiline matching: By default patterns match within single lines only. For cross-line patterns like `struct \{[\s\S]*?field`, use `multiline: true`
|
|
14
|
+
- When investigating several patterns or directories at once, queue every `grep` call within the same response so they form a single batched execution.
|
|
14
15
|
</description>
|
|
15
16
|
|
|
16
17
|
<parameters>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
2
|
<tool_prompt>
|
|
3
3
|
<description>
|
|
4
|
-
Lists files and directories in a given path. The path parameter must be an absolute path, not a relative path. You can optionally provide an array of glob patterns to ignore with the ignore parameter. You should generally prefer the Glob and Grep tools, if you know which directories to search.
|
|
4
|
+
Lists files and directories in a given path. The path parameter must be an absolute path, not a relative path. You can optionally provide an array of glob patterns to ignore with the ignore parameter. You should generally prefer the Glob and Grep tools, if you know which directories to search. When inspecting multiple directories, enumerate every `list_dir` call you intend to run in the same response so they execute together as a parallel batch.
|
|
5
5
|
</description>
|
|
6
6
|
|
|
7
7
|
<parameters>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<tool>
|
|
2
|
+
<description>
|
|
3
|
+
Record a ReAct-style think/observe timeline, retrieve it, or clear it for the current session.
|
|
4
|
+
</description>
|
|
5
|
+
<parameters>
|
|
6
|
+
<parameter name="action" required="true">
|
|
7
|
+
<type>string</type>
|
|
8
|
+
<description>One of think, observe, get, clear.</description>
|
|
9
|
+
</parameter>
|
|
10
|
+
<parameter name="thoughts" required="false">
|
|
11
|
+
<type>string</type>
|
|
12
|
+
<description>Reasoning text for think entries.</description>
|
|
13
|
+
</parameter>
|
|
14
|
+
<parameter name="next_action" required="false">
|
|
15
|
+
<type>string</type>
|
|
16
|
+
<description>Planned action to pair with think entries.</description>
|
|
17
|
+
</parameter>
|
|
18
|
+
<parameter name="result" required="false">
|
|
19
|
+
<type>string</type>
|
|
20
|
+
<description>Observation details for observe entries.</description>
|
|
21
|
+
</parameter>
|
|
22
|
+
</parameters>
|
|
23
|
+
</tool>
|
|
@@ -13,7 +13,7 @@ Usage:
|
|
|
13
13
|
- This tool allows Claude Code to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as Claude Code is a multimodal LLM.
|
|
14
14
|
- This tool can read PDF files (.pdf). PDFs are processed page by page, extracting both text and visual content for analysis.
|
|
15
15
|
- This tool can read Jupyter notebooks (.ipynb files) and returns all cells with their outputs, combining code, text, and visualizations.
|
|
16
|
-
- You have the capability to call multiple tools in a single response.
|
|
16
|
+
- You have the capability to call multiple tools in a single response. Enumerate every file you plan to inspect so multiple `read_file` calls can run in parallel rather than waiting for sequential turns.
|
|
17
17
|
- You will regularly be asked to read screenshots. If the user provides a path to a screenshot ALWAYS use this tool to view the file at the path. This tool will work with all temporary file paths like /var/folders/123/abc/T/TemporaryItems/NSIRD_screencaptureui_ZfB1tD/Screenshot.png
|
|
18
18
|
- If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents.
|
|
19
19
|
</description>
|
tunacode/tools/react.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Lightweight ReAct-style scratchpad tool."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Dict, Literal
|
|
7
|
+
|
|
8
|
+
import defusedxml.ElementTree as ET
|
|
9
|
+
from pydantic_ai.exceptions import ModelRetry
|
|
10
|
+
|
|
11
|
+
from tunacode.core.state import StateManager
|
|
12
|
+
from tunacode.types import ToolResult, UILogger
|
|
13
|
+
|
|
14
|
+
from .base import BaseTool
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# CLAUDE_ANCHOR[react-tool]: Minimal ReAct scratchpad tool surface
|
|
18
|
+
class ReactTool(BaseTool):
|
|
19
|
+
"""Minimal ReAct scratchpad for tracking think/observe steps."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, state_manager: StateManager, ui_logger: UILogger | None = None):
|
|
22
|
+
super().__init__(ui_logger)
|
|
23
|
+
self.state_manager = state_manager
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def tool_name(self) -> str:
|
|
27
|
+
return "react"
|
|
28
|
+
|
|
29
|
+
async def _execute(
|
|
30
|
+
self,
|
|
31
|
+
action: Literal["think", "observe", "get", "clear"],
|
|
32
|
+
thoughts: str | None = None,
|
|
33
|
+
next_action: str | None = None,
|
|
34
|
+
result: str | None = None,
|
|
35
|
+
) -> ToolResult:
|
|
36
|
+
scratchpad = self._ensure_scratchpad()
|
|
37
|
+
|
|
38
|
+
if action == "think":
|
|
39
|
+
if not thoughts:
|
|
40
|
+
raise ModelRetry("Provide thoughts when using react think action")
|
|
41
|
+
if not next_action:
|
|
42
|
+
raise ModelRetry("Specify next_action when recording react thoughts")
|
|
43
|
+
|
|
44
|
+
entry = {
|
|
45
|
+
"type": "think",
|
|
46
|
+
"thoughts": thoughts,
|
|
47
|
+
"next_action": next_action,
|
|
48
|
+
}
|
|
49
|
+
self.state_manager.append_react_entry(entry)
|
|
50
|
+
return "Recorded think step"
|
|
51
|
+
|
|
52
|
+
if action == "observe":
|
|
53
|
+
if not result:
|
|
54
|
+
raise ModelRetry("Provide result when using react observe action")
|
|
55
|
+
|
|
56
|
+
entry = {
|
|
57
|
+
"type": "observe",
|
|
58
|
+
"result": result,
|
|
59
|
+
}
|
|
60
|
+
self.state_manager.append_react_entry(entry)
|
|
61
|
+
return "Recorded observation"
|
|
62
|
+
|
|
63
|
+
if action == "get":
|
|
64
|
+
timeline = scratchpad.get("timeline", [])
|
|
65
|
+
if not timeline:
|
|
66
|
+
return "React scratchpad is empty"
|
|
67
|
+
|
|
68
|
+
formatted = [
|
|
69
|
+
f"{index + 1}. {item['type']}: {self._format_entry(item)}"
|
|
70
|
+
for index, item in enumerate(timeline)
|
|
71
|
+
]
|
|
72
|
+
return "\n".join(formatted)
|
|
73
|
+
|
|
74
|
+
if action == "clear":
|
|
75
|
+
self.state_manager.clear_react_scratchpad()
|
|
76
|
+
return "React scratchpad cleared"
|
|
77
|
+
|
|
78
|
+
raise ModelRetry("Invalid react action. Use one of: think, observe, get, clear")
|
|
79
|
+
|
|
80
|
+
def _format_entry(self, item: Dict[str, Any]) -> str:
|
|
81
|
+
if item["type"] == "think":
|
|
82
|
+
return f"thoughts='{item['thoughts']}', next_action='{item['next_action']}'"
|
|
83
|
+
if item["type"] == "observe":
|
|
84
|
+
return f"result='{item['result']}'"
|
|
85
|
+
return str(item)
|
|
86
|
+
|
|
87
|
+
def _ensure_scratchpad(self) -> dict[str, Any]:
|
|
88
|
+
scratchpad = self.state_manager.get_react_scratchpad()
|
|
89
|
+
scratchpad.setdefault("timeline", [])
|
|
90
|
+
return scratchpad
|
|
91
|
+
|
|
92
|
+
def _get_base_prompt(self) -> str:
|
|
93
|
+
prompt_file = Path(__file__).parent / "prompts" / "react_prompt.xml"
|
|
94
|
+
if prompt_file.exists():
|
|
95
|
+
try:
|
|
96
|
+
tree = ET.parse(prompt_file)
|
|
97
|
+
root = tree.getroot()
|
|
98
|
+
description = root.find("description")
|
|
99
|
+
if description is not None and description.text:
|
|
100
|
+
return description.text.strip()
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
|
103
|
+
return "Use this tool to record think/observe notes and manage the react scratchpad"
|
|
104
|
+
|
|
105
|
+
def _get_parameters_schema(self) -> Dict[str, Any]:
|
|
106
|
+
prompt_file = Path(__file__).parent / "prompts" / "react_prompt.xml"
|
|
107
|
+
if prompt_file.exists():
|
|
108
|
+
try:
|
|
109
|
+
tree = ET.parse(prompt_file)
|
|
110
|
+
root = tree.getroot()
|
|
111
|
+
parameters = root.find("parameters")
|
|
112
|
+
if parameters is not None:
|
|
113
|
+
schema: Dict[str, Any] = {
|
|
114
|
+
"type": "object",
|
|
115
|
+
"properties": {},
|
|
116
|
+
"required": ["action"],
|
|
117
|
+
}
|
|
118
|
+
for param in parameters.findall("parameter"):
|
|
119
|
+
name = param.get("name")
|
|
120
|
+
param_type = param.find("type")
|
|
121
|
+
description = param.find("description")
|
|
122
|
+
if name and param_type is not None:
|
|
123
|
+
schema["properties"][name] = {
|
|
124
|
+
"type": param_type.text.strip(),
|
|
125
|
+
"description": description.text.strip()
|
|
126
|
+
if description is not None and description.text
|
|
127
|
+
else "",
|
|
128
|
+
}
|
|
129
|
+
return schema
|
|
130
|
+
except Exception:
|
|
131
|
+
pass
|
|
132
|
+
return {
|
|
133
|
+
"type": "object",
|
|
134
|
+
"properties": {
|
|
135
|
+
"action": {
|
|
136
|
+
"type": "string",
|
|
137
|
+
"description": "react operation to perform",
|
|
138
|
+
},
|
|
139
|
+
"thoughts": {
|
|
140
|
+
"type": "string",
|
|
141
|
+
"description": "Thought content for think action",
|
|
142
|
+
},
|
|
143
|
+
"next_action": {
|
|
144
|
+
"type": "string",
|
|
145
|
+
"description": "Planned next action for think action",
|
|
146
|
+
},
|
|
147
|
+
"result": {
|
|
148
|
+
"type": "string",
|
|
149
|
+
"description": "Observation message for observe action",
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
"required": ["action"],
|
|
153
|
+
}
|
tunacode/tools/run_command.py
CHANGED
|
@@ -144,6 +144,7 @@ class RunCommandTool(BaseTool):
|
|
|
144
144
|
CommandSecurityError: If command fails security validation
|
|
145
145
|
Exception: Any command execution errors
|
|
146
146
|
"""
|
|
147
|
+
process = None
|
|
147
148
|
try:
|
|
148
149
|
# Use secure subprocess execution with validation
|
|
149
150
|
process = safe_subprocess_popen(
|
|
@@ -158,6 +159,20 @@ class RunCommandTool(BaseTool):
|
|
|
158
159
|
except CommandSecurityError as e:
|
|
159
160
|
# Security validation failed - return error without execution
|
|
160
161
|
return f"Security validation failed: {str(e)}"
|
|
162
|
+
finally:
|
|
163
|
+
# Ensure process cleanup regardless of success or failure
|
|
164
|
+
if process is not None and process.poll() is None:
|
|
165
|
+
try:
|
|
166
|
+
# Multi-stage escalation: graceful → terminate → kill
|
|
167
|
+
process.terminate()
|
|
168
|
+
try:
|
|
169
|
+
process.wait(timeout=5.0)
|
|
170
|
+
except subprocess.TimeoutExpired:
|
|
171
|
+
process.kill()
|
|
172
|
+
process.wait(timeout=1.0)
|
|
173
|
+
except Exception as cleanup_error:
|
|
174
|
+
self.logger.warning(f"Failed to cleanup process: {cleanup_error}")
|
|
175
|
+
|
|
161
176
|
output = stdout.strip() or CMD_OUTPUT_NO_OUTPUT
|
|
162
177
|
error = stderr.strip() or CMD_OUTPUT_NO_ERRORS
|
|
163
178
|
resp = CMD_OUTPUT_FORMAT.format(output=output, error=error).strip()
|