tunacode-cli 0.0.56__py3-none-any.whl → 0.0.60__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 (45) hide show
  1. tunacode/cli/commands/implementations/plan.py +8 -8
  2. tunacode/cli/commands/registry.py +2 -2
  3. tunacode/cli/repl.py +214 -407
  4. tunacode/cli/repl_components/command_parser.py +37 -4
  5. tunacode/cli/repl_components/error_recovery.py +79 -1
  6. tunacode/cli/repl_components/output_display.py +14 -11
  7. tunacode/cli/repl_components/tool_executor.py +7 -4
  8. tunacode/configuration/defaults.py +8 -0
  9. tunacode/constants.py +8 -2
  10. tunacode/core/agents/agent_components/agent_config.py +128 -65
  11. tunacode/core/agents/agent_components/node_processor.py +6 -2
  12. tunacode/core/code_index.py +83 -29
  13. tunacode/core/state.py +1 -1
  14. tunacode/core/token_usage/usage_tracker.py +2 -2
  15. tunacode/core/tool_handler.py +3 -3
  16. tunacode/prompts/system.md +117 -490
  17. tunacode/services/mcp.py +29 -7
  18. tunacode/tools/base.py +110 -0
  19. tunacode/tools/bash.py +96 -1
  20. tunacode/tools/exit_plan_mode.py +114 -32
  21. tunacode/tools/glob.py +366 -33
  22. tunacode/tools/grep.py +226 -77
  23. tunacode/tools/grep_components/result_formatter.py +98 -4
  24. tunacode/tools/list_dir.py +132 -2
  25. tunacode/tools/present_plan.py +111 -31
  26. tunacode/tools/read_file.py +91 -0
  27. tunacode/tools/run_command.py +99 -0
  28. tunacode/tools/schema_assembler.py +167 -0
  29. tunacode/tools/todo.py +108 -1
  30. tunacode/tools/update_file.py +94 -0
  31. tunacode/tools/write_file.py +86 -0
  32. tunacode/types.py +10 -9
  33. tunacode/ui/input.py +1 -0
  34. tunacode/ui/keybindings.py +1 -0
  35. tunacode/ui/panels.py +49 -27
  36. tunacode/ui/prompt_manager.py +13 -7
  37. tunacode/utils/json_utils.py +206 -0
  38. tunacode/utils/ripgrep.py +332 -9
  39. {tunacode_cli-0.0.56.dist-info → tunacode_cli-0.0.60.dist-info}/METADATA +7 -2
  40. {tunacode_cli-0.0.56.dist-info → tunacode_cli-0.0.60.dist-info}/RECORD +44 -43
  41. tunacode/tools/read_file_async_poc.py +0 -196
  42. {tunacode_cli-0.0.56.dist-info → tunacode_cli-0.0.60.dist-info}/WHEEL +0 -0
  43. {tunacode_cli-0.0.56.dist-info → tunacode_cli-0.0.60.dist-info}/entry_points.txt +0 -0
  44. {tunacode_cli-0.0.56.dist-info → tunacode_cli-0.0.60.dist-info}/licenses/LICENSE +0 -0
  45. {tunacode_cli-0.0.56.dist-info → tunacode_cli-0.0.60.dist-info}/top_level.txt +0 -0
tunacode/services/mcp.py CHANGED
@@ -7,7 +7,7 @@ Handles MCP server initialization, configuration validation, and client connecti
7
7
 
8
8
  import os
9
9
  from contextlib import asynccontextmanager
10
- from typing import TYPE_CHECKING, AsyncIterator, List, Optional, Tuple
10
+ from typing import TYPE_CHECKING, AsyncIterator, Dict, List, Optional, Tuple
11
11
 
12
12
  from pydantic_ai.mcp import MCPServerStdio
13
13
 
@@ -19,6 +19,10 @@ if TYPE_CHECKING:
19
19
 
20
20
  from tunacode.core.state import StateManager
21
21
 
22
+ # Module-level cache for MCP server instances
23
+ _MCP_SERVER_CACHE: Dict[str, MCPServerStdio] = {}
24
+ _MCP_CONFIG_HASH: Optional[int] = None
25
+
22
26
 
23
27
  class QuietMCPServer(MCPServerStdio):
24
28
  """A version of ``MCPServerStdio`` that suppresses *all* output coming from the
@@ -55,27 +59,45 @@ class QuietMCPServer(MCPServerStdio):
55
59
 
56
60
 
57
61
  def get_mcp_servers(state_manager: "StateManager") -> List[MCPServerStdio]:
58
- """Load MCP servers from configuration.
62
+ """Load MCP servers from configuration with caching.
59
63
 
60
64
  Args:
61
65
  state_manager: The state manager containing user configuration
62
66
 
63
67
  Returns:
64
- List of MCP server instances
68
+ List of MCP server instances (cached when possible)
65
69
 
66
70
  Raises:
67
71
  MCPError: If a server configuration is invalid
68
72
  """
73
+ global _MCP_CONFIG_HASH
74
+
69
75
  mcp_servers: MCPServers = state_manager.session.user_config.get("mcpServers", {})
76
+
77
+ # Calculate hash of current config
78
+ current_hash = hash(str(mcp_servers))
79
+
80
+ # Check if config has changed
81
+ if _MCP_CONFIG_HASH == current_hash and _MCP_SERVER_CACHE:
82
+ # Return cached servers
83
+ return list(_MCP_SERVER_CACHE.values())
84
+
85
+ # Config changed or first load - clear cache and rebuild
86
+ _MCP_SERVER_CACHE.clear()
87
+ _MCP_CONFIG_HASH = current_hash
88
+
70
89
  loaded_servers: List[MCPServerStdio] = []
71
90
  MCPServerStdio.log_level = "critical"
72
91
 
73
92
  for server_name, conf in mcp_servers.items():
74
93
  try:
75
- # loaded_servers.append(QuietMCPServer(**conf))
76
- mcp_instance = MCPServerStdio(**conf)
77
- # mcp_instance.log_level = "critical"
78
- loaded_servers.append(mcp_instance)
94
+ # Check if this server is already cached
95
+ if server_name not in _MCP_SERVER_CACHE:
96
+ # Create new instance
97
+ mcp_instance = MCPServerStdio(**conf)
98
+ _MCP_SERVER_CACHE[server_name] = mcp_instance
99
+
100
+ loaded_servers.append(_MCP_SERVER_CACHE[server_name])
79
101
  except Exception as e:
80
102
  raise MCPError(
81
103
  server_name=server_name,
tunacode/tools/base.py CHANGED
@@ -5,6 +5,7 @@ for all tools including error handling, UI logging, and ModelRetry support.
5
5
  """
6
6
 
7
7
  from abc import ABC, abstractmethod
8
+ from typing import Any, Dict, Optional
8
9
 
9
10
  from pydantic_ai.exceptions import ModelRetry
10
11
 
@@ -24,6 +25,8 @@ class BaseTool(ABC):
24
25
  """
25
26
  self.ui = ui_logger
26
27
  self.logger = get_logger(self.__class__.__name__)
28
+ self._prompt_cache: Optional[str] = None
29
+ self._context: Dict[str, Any] = {}
27
30
 
28
31
  async def execute(self, *args, **kwargs) -> ToolResult:
29
32
  """Execute the tool with error handling and logging.
@@ -138,6 +141,113 @@ class BaseTool(ABC):
138
141
  """
139
142
  return f"in {self.tool_name}"
140
143
 
144
+ def prompt(self, context: Optional[Dict[str, Any]] = None) -> str:
145
+ """Generate the prompt for this tool.
146
+
147
+ Args:
148
+ context: Optional context including model, permissions, environment
149
+
150
+ Returns:
151
+ str: The generated prompt for this tool
152
+ """
153
+ # Update context if provided
154
+ if context:
155
+ self._context.update(context)
156
+
157
+ # Check cache if context hasn't changed
158
+ cache_key = str(sorted(self._context.items()))
159
+ if self._prompt_cache and cache_key == getattr(self, "_cache_key", None):
160
+ return self._prompt_cache
161
+
162
+ # Generate new prompt
163
+ prompt = self._generate_prompt()
164
+
165
+ # Cache the result
166
+ self._prompt_cache = prompt
167
+ self._cache_key = cache_key
168
+
169
+ return prompt
170
+
171
+ def _generate_prompt(self) -> str:
172
+ """Generate the actual prompt based on current context.
173
+
174
+ Override this method in subclasses to provide tool-specific prompts.
175
+
176
+ Returns:
177
+ str: The generated prompt
178
+ """
179
+ # Default prompt generation
180
+ base_prompt = self._get_base_prompt()
181
+
182
+ # Apply model-specific adjustments
183
+ if "model" in self._context:
184
+ base_prompt = self._adjust_for_model(base_prompt, self._context["model"])
185
+
186
+ # Apply permission-specific adjustments
187
+ if "permissions" in self._context:
188
+ base_prompt = self._adjust_for_permissions(base_prompt, self._context["permissions"])
189
+
190
+ return base_prompt
191
+
192
+ def _get_base_prompt(self) -> str:
193
+ """Get the base prompt for this tool.
194
+
195
+ Override this in subclasses to provide tool-specific base prompts.
196
+
197
+ Returns:
198
+ str: The base prompt template
199
+ """
200
+ return f"Execute the {self.tool_name} tool to perform its designated operation."
201
+
202
+ def _adjust_for_model(self, prompt: str, model: str) -> str:
203
+ """Adjust prompt based on the model being used.
204
+
205
+ Args:
206
+ prompt: The base prompt
207
+ model: The model identifier
208
+
209
+ Returns:
210
+ str: Adjusted prompt
211
+ """
212
+ # Default implementation - override in subclasses for specific adjustments
213
+ return prompt
214
+
215
+ def _adjust_for_permissions(self, prompt: str, permissions: Dict[str, Any]) -> str:
216
+ """Adjust prompt based on permissions.
217
+
218
+ Args:
219
+ prompt: The base prompt
220
+ permissions: Permission settings
221
+
222
+ Returns:
223
+ str: Adjusted prompt
224
+ """
225
+ # Default implementation - override in subclasses for specific adjustments
226
+ return prompt
227
+
228
+ def get_tool_schema(self) -> Dict[str, Any]:
229
+ """Generate the tool schema for API integration.
230
+
231
+ Returns:
232
+ Dict containing the tool schema in OpenAI function format
233
+ """
234
+ return {
235
+ "name": self.tool_name,
236
+ "description": self.prompt(),
237
+ "parameters": self._get_parameters_schema(),
238
+ }
239
+
240
+ @abstractmethod
241
+ def _get_parameters_schema(self) -> Dict[str, Any]:
242
+ """Get the parameters schema for this tool.
243
+
244
+ Must be implemented by subclasses.
245
+
246
+ Returns:
247
+ Dict containing the JSON schema for tool parameters
248
+ """
249
+ pass
250
+
141
251
 
142
252
  class FileBasedTool(BaseTool):
143
253
  """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,
@@ -1,29 +1,108 @@
1
1
  """Tool for exiting plan mode and presenting implementation plan."""
2
2
 
3
+ import logging
4
+ from pathlib import Path
3
5
  from typing import Any, Dict, List
4
6
 
7
+ import defusedxml.ElementTree as ET
8
+
5
9
  from tunacode.tools.base import BaseTool
6
- from tunacode.ui import console as ui
7
10
  from tunacode.types import ToolResult
11
+ from tunacode.ui import console as ui
12
+
13
+ logger = logging.getLogger(__name__)
8
14
 
9
15
 
10
16
  class ExitPlanModeTool(BaseTool):
11
17
  """Present implementation plan and exit plan mode."""
12
-
18
+
13
19
  def __init__(self, state_manager, ui_logger=None):
14
20
  """Initialize the exit plan mode tool.
15
-
21
+
16
22
  Args:
17
23
  state_manager: StateManager instance for controlling plan mode state
18
24
  ui_logger: UI logger instance for displaying messages
19
25
  """
20
26
  super().__init__(ui_logger)
21
27
  self.state_manager = state_manager
22
-
28
+
23
29
  @property
24
30
  def tool_name(self) -> str:
25
31
  return "exit_plan_mode"
26
-
32
+
33
+ def _get_base_prompt(self) -> str:
34
+ """Load and return the base prompt from XML file.
35
+
36
+ Returns:
37
+ str: The loaded prompt from XML or a default prompt
38
+ """
39
+ try:
40
+ # Load prompt from XML file
41
+ prompt_file = Path(__file__).parent / "prompts" / "exit_plan_mode_prompt.xml"
42
+ if prompt_file.exists():
43
+ tree = ET.parse(prompt_file)
44
+ root = tree.getroot()
45
+ description = root.find("description")
46
+ if description is not None:
47
+ return description.text.strip()
48
+ except Exception as e:
49
+ logger.warning(f"Failed to load XML prompt for exit_plan_mode: {e}")
50
+
51
+ # Fallback to default prompt
52
+ return """Use this tool when you have finished presenting your plan and are ready to code"""
53
+
54
+ def _get_parameters_schema(self) -> Dict[str, Any]:
55
+ """Get the parameters schema for exit_plan_mode tool.
56
+
57
+ Returns:
58
+ Dict containing the JSON schema for tool parameters
59
+ """
60
+ # Try to load from XML first
61
+ try:
62
+ prompt_file = Path(__file__).parent / "prompts" / "exit_plan_mode_prompt.xml"
63
+ if prompt_file.exists():
64
+ tree = ET.parse(prompt_file)
65
+ root = tree.getroot()
66
+ parameters = root.find("parameters")
67
+ if parameters is not None:
68
+ schema: Dict[str, Any] = {"type": "object", "properties": {}, "required": []}
69
+ required_fields: List[str] = []
70
+
71
+ for param in parameters.findall("parameter"):
72
+ name = param.get("name")
73
+ required = param.get("required", "false").lower() == "true"
74
+ param_type = param.find("type")
75
+ description = param.find("description")
76
+
77
+ if name and param_type is not None:
78
+ prop = {
79
+ "type": param_type.text.strip(),
80
+ "description": description.text.strip()
81
+ if description is not None
82
+ else "",
83
+ }
84
+
85
+ schema["properties"][name] = prop
86
+ if required:
87
+ required_fields.append(name)
88
+
89
+ schema["required"] = required_fields
90
+ return schema
91
+ except Exception as e:
92
+ logger.warning(f"Failed to load parameters from XML for exit_plan_mode: {e}")
93
+
94
+ # Fallback to hardcoded schema
95
+ return {
96
+ "type": "object",
97
+ "properties": {
98
+ "plan": {
99
+ "type": "string",
100
+ "description": "The plan you came up with",
101
+ },
102
+ },
103
+ "required": ["plan"],
104
+ }
105
+
27
106
  async def _execute(
28
107
  self,
29
108
  plan_title: str,
@@ -36,7 +115,7 @@ class ExitPlanModeTool(BaseTool):
36
115
  success_criteria: List[str] = None,
37
116
  ) -> ToolResult:
38
117
  """Present the implementation plan and get user approval."""
39
-
118
+
40
119
  plan = {
41
120
  "title": plan_title,
42
121
  "overview": overview,
@@ -45,15 +124,15 @@ class ExitPlanModeTool(BaseTool):
45
124
  "implementation_steps": implementation_steps,
46
125
  "risks_and_considerations": risks_and_considerations or [],
47
126
  "testing_approach": testing_approach or "Manual testing of functionality",
48
- "success_criteria": success_criteria or []
127
+ "success_criteria": success_criteria or [],
49
128
  }
50
-
129
+
51
130
  # Present plan to user
52
131
  await self._present_plan(plan)
53
-
132
+
54
133
  # Get user approval
55
134
  approved = await self._get_user_approval()
56
-
135
+
57
136
  # Update state based on user approval
58
137
  if approved:
59
138
  # Store the plan and exit plan mode
@@ -66,7 +145,7 @@ class ExitPlanModeTool(BaseTool):
66
145
  self.state_manager.set_current_plan(plan)
67
146
  await ui.warning("❌ Plan rejected. Staying in Plan Mode for further research.")
68
147
  return "Plan rejected. Continue researching and refine your approach. You remain in Plan Mode - only read-only tools are available."
69
-
148
+
70
149
  async def _present_plan(self, plan: Dict[str, Any]) -> None:
71
150
  """Present the plan in a formatted way."""
72
151
  # Build the entire plan output as a single string to avoid UI flooding
@@ -78,64 +157,66 @@ class ExitPlanModeTool(BaseTool):
78
157
  output.append("")
79
158
  output.append(f"🎯 {plan['title']}")
80
159
  output.append("")
81
-
160
+
82
161
  if plan["overview"]:
83
162
  output.append(f"📝 Overview: {plan['overview']}")
84
163
  output.append("")
85
-
164
+
86
165
  # Files section
87
166
  if plan["files_to_modify"]:
88
167
  output.append("📝 Files to Modify:")
89
168
  for f in plan["files_to_modify"]:
90
169
  output.append(f" • {f}")
91
170
  output.append("")
92
-
171
+
93
172
  if plan["files_to_create"]:
94
173
  output.append("📄 Files to Create:")
95
174
  for f in plan["files_to_create"]:
96
175
  output.append(f" • {f}")
97
176
  output.append("")
98
-
177
+
99
178
  # Implementation steps
100
179
  output.append("🔧 Implementation Steps:")
101
180
  for i, step in enumerate(plan["implementation_steps"], 1):
102
181
  output.append(f" {i}. {step}")
103
182
  output.append("")
104
-
183
+
105
184
  # Testing approach
106
185
  if plan["testing_approach"]:
107
186
  output.append(f"🧪 Testing Approach: {plan['testing_approach']}")
108
187
  output.append("")
109
-
188
+
110
189
  # Success criteria
111
190
  if plan["success_criteria"]:
112
191
  output.append("✅ Success Criteria:")
113
192
  for criteria in plan["success_criteria"]:
114
193
  output.append(f" • {criteria}")
115
194
  output.append("")
116
-
195
+
117
196
  # Risks and considerations
118
197
  if plan["risks_and_considerations"]:
119
198
  output.append("⚠️ Risks & Considerations:")
120
199
  for risk in plan["risks_and_considerations"]:
121
200
  output.append(f" • {risk}")
122
201
  output.append("")
123
-
202
+
124
203
  # Print everything at once
125
204
  await ui.info("\n".join(output))
126
-
205
+
127
206
  async def _get_user_approval(self) -> bool:
128
207
  """Get user approval for the plan."""
129
208
  try:
130
209
  from prompt_toolkit import PromptSession
131
210
  from prompt_toolkit.patch_stdout import patch_stdout
132
-
211
+
133
212
  session = PromptSession()
134
-
213
+
135
214
  with patch_stdout():
136
- response = await session.prompt_async("\n🤔 Approve this implementation plan? (y/n): ")
137
-
138
- return response.strip().lower() in ['y', 'yes', 'approve']
215
+ response = await session.prompt_async(
216
+ "\n🤔 Approve this implementation plan? (y/n): "
217
+ )
218
+
219
+ return response.strip().lower() in ["y", "yes", "approve"]
139
220
  except (KeyboardInterrupt, EOFError):
140
221
  return False
141
222
 
@@ -143,13 +224,14 @@ class ExitPlanModeTool(BaseTool):
143
224
  def create_exit_plan_mode_tool(state_manager):
144
225
  """
145
226
  Factory function to create exit_plan_mode tool with the correct state manager.
146
-
227
+
147
228
  Args:
148
229
  state_manager: The StateManager instance to use
149
-
230
+
150
231
  Returns:
151
232
  Callable: The exit_plan_mode function bound to the provided state manager
152
233
  """
234
+
153
235
  async def exit_plan_mode(
154
236
  plan_title: str,
155
237
  overview: str,
@@ -162,17 +244,17 @@ def create_exit_plan_mode_tool(state_manager):
162
244
  ) -> str:
163
245
  """
164
246
  Present implementation plan and exit plan mode.
165
-
247
+
166
248
  Args:
167
249
  plan_title: Brief title for the implementation plan
168
250
  overview: High-level overview of the changes needed
169
- implementation_steps: Ordered list of implementation steps
251
+ implementation_steps: Ordered list of implementation steps
170
252
  files_to_modify: List of files that need to be modified
171
253
  files_to_create: List of new files to be created
172
254
  risks_and_considerations: Potential risks or important considerations
173
255
  testing_approach: Approach for testing the implementation
174
256
  success_criteria: Criteria for considering the implementation successful
175
-
257
+
176
258
  Returns:
177
259
  str: Result message indicating plan approval status
178
260
  """
@@ -187,5 +269,5 @@ def create_exit_plan_mode_tool(state_manager):
187
269
  testing_approach=testing_approach,
188
270
  success_criteria=success_criteria,
189
271
  )
190
-
191
- return exit_plan_mode
272
+
273
+ return exit_plan_mode