tunacode-cli 0.0.55__py3-none-any.whl → 0.0.57__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 (47) hide show
  1. tunacode/cli/commands/implementations/plan.py +50 -0
  2. tunacode/cli/commands/registry.py +3 -0
  3. tunacode/cli/repl.py +327 -186
  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 +21 -1
  7. tunacode/cli/repl_components/tool_executor.py +12 -0
  8. tunacode/configuration/defaults.py +8 -0
  9. tunacode/constants.py +10 -2
  10. tunacode/core/agents/agent_components/agent_config.py +212 -22
  11. tunacode/core/agents/agent_components/node_processor.py +46 -40
  12. tunacode/core/code_index.py +83 -29
  13. tunacode/core/state.py +44 -0
  14. tunacode/core/token_usage/usage_tracker.py +2 -2
  15. tunacode/core/tool_handler.py +20 -0
  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 +273 -0
  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 +288 -0
  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 +58 -0
  33. tunacode/ui/input.py +14 -2
  34. tunacode/ui/keybindings.py +25 -4
  35. tunacode/ui/panels.py +53 -8
  36. tunacode/ui/prompt_manager.py +25 -2
  37. tunacode/ui/tool_ui.py +3 -2
  38. tunacode/utils/json_utils.py +206 -0
  39. tunacode/utils/message_utils.py +14 -4
  40. tunacode/utils/ripgrep.py +332 -9
  41. {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.57.dist-info}/METADATA +8 -3
  42. {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.57.dist-info}/RECORD +46 -42
  43. tunacode/tools/read_file_async_poc.py +0 -196
  44. {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.57.dist-info}/WHEEL +0 -0
  45. {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.57.dist-info}/entry_points.txt +0 -0
  46. {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.57.dist-info}/licenses/LICENSE +0 -0
  47. {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.57.dist-info}/top_level.txt +0 -0
@@ -5,14 +5,24 @@ Command parsing utilities for the REPL.
5
5
  """
6
6
 
7
7
  import json
8
+ import logging
8
9
 
10
+ from tunacode.constants import (
11
+ JSON_PARSE_BASE_DELAY,
12
+ JSON_PARSE_MAX_DELAY,
13
+ JSON_PARSE_MAX_RETRIES,
14
+ )
9
15
  from tunacode.exceptions import ValidationError
10
16
  from tunacode.types import ToolArgs
17
+ from tunacode.utils.json_utils import safe_json_parse
18
+ from tunacode.utils.retry import retry_json_parse
19
+
20
+ logger = logging.getLogger(__name__)
11
21
 
12
22
 
13
23
  def parse_args(args) -> ToolArgs:
14
24
  """
15
- Parse tool arguments from a JSON string or dictionary.
25
+ Parse tool arguments from a JSON string or dictionary with retry logic.
16
26
 
17
27
  Args:
18
28
  args (str or dict): A JSON-formatted string or a dictionary containing tool arguments.
@@ -21,12 +31,35 @@ def parse_args(args) -> ToolArgs:
21
31
  dict: The parsed arguments.
22
32
 
23
33
  Raises:
24
- ValueError: If 'args' is not a string or dictionary, or if the string is not valid JSON.
34
+ ValidationError: If 'args' is not a string or dictionary, or if the string is not valid JSON.
25
35
  """
26
36
  if isinstance(args, str):
27
37
  try:
28
- return json.loads(args)
29
- except json.JSONDecodeError:
38
+ # First attempt: Use retry logic for transient failures
39
+ return retry_json_parse(
40
+ args,
41
+ max_retries=JSON_PARSE_MAX_RETRIES,
42
+ base_delay=JSON_PARSE_BASE_DELAY,
43
+ max_delay=JSON_PARSE_MAX_DELAY,
44
+ )
45
+ except json.JSONDecodeError as e:
46
+ # Check if this is an "Extra data" error (concatenated JSON objects)
47
+ if "Extra data" in str(e):
48
+ logger.warning(f"Detected concatenated JSON objects in args: {args[:200]}...")
49
+ try:
50
+ # Use the new safe JSON parser with concatenation support
51
+ result = safe_json_parse(args, allow_concatenated=True)
52
+ if isinstance(result, dict):
53
+ return result
54
+ elif isinstance(result, list) and result:
55
+ # Multiple objects - return first one
56
+ logger.warning("Multiple JSON objects detected, using first object only")
57
+ return result[0]
58
+ except Exception:
59
+ # If safe parsing also fails, fall through to original error
60
+ pass
61
+
62
+ # Original error - no recovery possible
30
63
  raise ValidationError(f"Invalid JSON: {args}")
31
64
  elif isinstance(args, dict):
32
65
  return args
@@ -14,6 +14,77 @@ from .tool_executor import tool_handler
14
14
  logger = logging.getLogger(__name__)
15
15
 
16
16
  MSG_JSON_RECOVERY = "Recovered using JSON tool parsing"
17
+ MSG_JSON_ARGS_RECOVERY = "Recovered from malformed tool arguments"
18
+
19
+
20
+ async def attempt_json_args_recovery(e: Exception, state_manager: StateManager) -> bool:
21
+ """
22
+ Attempt to recover from JSON parsing errors in tool arguments.
23
+
24
+ This handles cases where the model emits concatenated JSON objects
25
+ or other malformed JSON in tool call arguments.
26
+
27
+ Returns:
28
+ bool: True if recovery was successful, False otherwise
29
+ """
30
+ error_str = str(e).lower()
31
+
32
+ # Check if this is a JSON parsing error with tool arguments
33
+ if not any(
34
+ keyword in error_str for keyword in ["invalid json", "extra data", "jsondecodeerror"]
35
+ ):
36
+ return False
37
+
38
+ if not state_manager.session.messages:
39
+ return False
40
+
41
+ last_msg = state_manager.session.messages[-1]
42
+ if not hasattr(last_msg, "parts"):
43
+ return False
44
+
45
+ # Look for tool call parts with malformed args
46
+ for part in last_msg.parts:
47
+ if hasattr(part, "tool_name") and hasattr(part, "args"):
48
+ # This is a structured tool call with potentially malformed args
49
+ try:
50
+ from tunacode.utils.json_utils import split_concatenated_json
51
+
52
+ # Try to split concatenated JSON objects in the args
53
+ if isinstance(part.args, str):
54
+ logger.info(f"Attempting to recover malformed args for tool {part.tool_name}")
55
+
56
+ try:
57
+ json_objects = split_concatenated_json(part.args)
58
+ if json_objects:
59
+ # Use the first object as the args
60
+ part.args = json_objects[0]
61
+
62
+ # Execute the recovered tool call
63
+ await tool_handler(part, state_manager)
64
+
65
+ await ui.warning(f"Warning: {MSG_JSON_ARGS_RECOVERY}")
66
+ logger.info(
67
+ f"Successfully recovered tool {part.tool_name} with split JSON args",
68
+ extra={
69
+ "original_args": part.args,
70
+ "recovered_args": json_objects[0],
71
+ },
72
+ )
73
+ return True
74
+
75
+ except Exception as split_exc:
76
+ logger.debug(f"Failed to split JSON args: {split_exc}")
77
+ continue
78
+
79
+ except Exception as recovery_exc:
80
+ logger.error(
81
+ f"Error during JSON args recovery for tool {getattr(part, 'tool_name', 'unknown')}",
82
+ exc_info=True,
83
+ extra={"recovery_exception": str(recovery_exc)},
84
+ )
85
+ continue
86
+
87
+ return False
17
88
 
18
89
 
19
90
  async def attempt_tool_recovery(e: Exception, state_manager: StateManager) -> bool:
@@ -25,9 +96,16 @@ async def attempt_tool_recovery(e: Exception, state_manager: StateManager) -> bo
25
96
  """
26
97
  error_str = str(e).lower()
27
98
  tool_keywords = ["tool", "function", "call", "schema"]
28
- if not any(keyword in error_str for keyword in tool_keywords):
99
+ json_keywords = ["json", "invalid json", "jsondecodeerror", "extra data", "validation"]
100
+ recovery_keywords = tool_keywords + json_keywords
101
+
102
+ if not any(keyword in error_str for keyword in recovery_keywords):
29
103
  return False
30
104
 
105
+ # First, try JSON args recovery for structured tool calls with malformed args
106
+ if await attempt_json_args_recovery(e, state_manager):
107
+ return True
108
+
31
109
  if not state_manager.session.messages:
32
110
  return False
33
111
 
@@ -10,7 +10,7 @@ from tunacode.ui import console as ui
10
10
  MSG_REQUEST_COMPLETED = "Request completed"
11
11
 
12
12
 
13
- async def display_agent_output(res, enable_streaming: bool) -> None:
13
+ async def display_agent_output(res, enable_streaming: bool, state_manager=None) -> None:
14
14
  """Display agent output using guard clauses to flatten nested conditionals."""
15
15
  if enable_streaming:
16
16
  return
@@ -30,4 +30,24 @@ async def display_agent_output(res, enable_streaming: bool) -> None:
30
30
  if '"tool_uses"' in output:
31
31
  return
32
32
 
33
+ # In plan mode, don't display any agent text output at all
34
+ # The plan will be displayed via the present_plan tool
35
+ if state_manager and state_manager.is_plan_mode():
36
+ return
37
+
38
+ # Filter out plan mode system prompts and tool definitions
39
+ if any(
40
+ phrase in output
41
+ for phrase in [
42
+ "PLAN MODE - TOOL EXECUTION ONLY",
43
+ "🔧 PLAN MODE",
44
+ "TOOL EXECUTION ONLY 🔧",
45
+ "planning assistant that ONLY communicates",
46
+ "namespace functions {",
47
+ "namespace multi_tool_use {",
48
+ "You are trained on data up to",
49
+ ]
50
+ ):
51
+ return
52
+
33
53
  await ui.agent(output)
@@ -59,6 +59,18 @@ async def tool_handler(part, state_manager: StateManager):
59
59
  args = parse_args(part.args)
60
60
 
61
61
  def confirm_func():
62
+ # Check if tool is blocked in plan mode first
63
+ if tool_handler_instance.is_tool_blocked_in_plan_mode(part.tool_name):
64
+ from tunacode.constants import READ_ONLY_TOOLS
65
+
66
+ error_msg = (
67
+ f"🔍 Plan Mode: Tool '{part.tool_name}' is not available in Plan Mode.\n"
68
+ f"Only read-only tools are allowed: {', '.join(READ_ONLY_TOOLS)}\n"
69
+ f"Use 'exit_plan_mode' tool to present your plan and exit Plan Mode."
70
+ )
71
+ print(f"\n❌ {error_msg}\n")
72
+ return True # Abort the tool
73
+
62
74
  if not tool_handler_instance.should_confirm(part.tool_name):
63
75
  return False
64
76
  request = tool_handler_instance.create_confirmation_request(part.tool_name, args)
@@ -24,6 +24,14 @@ DEFAULT_USER_CONFIG: UserConfig = {
24
24
  "fallback_response": True,
25
25
  "fallback_verbosity": "normal", # Options: minimal, normal, detailed
26
26
  "context_window_size": 200000,
27
+ "ripgrep": {
28
+ "use_bundled": False, # Use system ripgrep binary
29
+ "timeout": 10, # Search timeout in seconds
30
+ "max_buffer_size": 1048576, # 1MB max output buffer
31
+ "max_results": 100, # Maximum results per search
32
+ "enable_metrics": False, # Enable performance metrics collection
33
+ "debug": False, # Enable debug logging for ripgrep operations
34
+ },
27
35
  },
28
36
  "mcpServers": {},
29
37
  }
tunacode/constants.py CHANGED
@@ -9,7 +9,7 @@ from enum import Enum
9
9
 
10
10
  # Application info
11
11
  APP_NAME = "TunaCode"
12
- APP_VERSION = "0.0.55"
12
+ APP_VERSION = "0.0.57"
13
13
 
14
14
 
15
15
  # File patterns
@@ -44,6 +44,7 @@ class ToolName(str, Enum):
44
44
  LIST_DIR = "list_dir"
45
45
  GLOB = "glob"
46
46
  TODO = "todo"
47
+ EXIT_PLAN_MODE = "exit_plan_mode"
47
48
 
48
49
 
49
50
  # Tool names (backward compatibility)
@@ -56,9 +57,16 @@ TOOL_GREP = ToolName.GREP
56
57
  TOOL_LIST_DIR = ToolName.LIST_DIR
57
58
  TOOL_GLOB = ToolName.GLOB
58
59
  TOOL_TODO = ToolName.TODO
60
+ TOOL_EXIT_PLAN_MODE = ToolName.EXIT_PLAN_MODE
59
61
 
60
62
  # Tool categorization
61
- READ_ONLY_TOOLS = [ToolName.READ_FILE, ToolName.GREP, ToolName.LIST_DIR, ToolName.GLOB]
63
+ READ_ONLY_TOOLS = [
64
+ ToolName.READ_FILE,
65
+ ToolName.GREP,
66
+ ToolName.LIST_DIR,
67
+ ToolName.GLOB,
68
+ ToolName.EXIT_PLAN_MODE,
69
+ ]
62
70
  WRITE_TOOLS = [ToolName.WRITE_FILE, ToolName.UPDATE_FILE]
63
71
  EXECUTE_TOOLS = [ToolName.BASH, ToolName.RUN_COMMAND]
64
72
 
@@ -1,6 +1,7 @@
1
1
  """Agent configuration and creation utilities."""
2
2
 
3
3
  from pathlib import Path
4
+ from typing import Dict, Tuple
4
5
 
5
6
  from pydantic_ai import Agent
6
7
 
@@ -11,6 +12,7 @@ from tunacode.tools.bash import bash
11
12
  from tunacode.tools.glob import glob
12
13
  from tunacode.tools.grep import grep
13
14
  from tunacode.tools.list_dir import list_dir
15
+ from tunacode.tools.present_plan import create_present_plan_tool
14
16
  from tunacode.tools.read_file import read_file
15
17
  from tunacode.tools.run_command import run_command
16
18
  from tunacode.tools.todo import TodoTool
@@ -20,6 +22,22 @@ from tunacode.types import ModelName, PydanticAgent
20
22
 
21
23
  logger = get_logger(__name__)
22
24
 
25
+ # Module-level caches for system prompts
26
+ _PROMPT_CACHE: Dict[str, Tuple[str, float]] = {}
27
+ _TUNACODE_CACHE: Dict[str, Tuple[str, float]] = {}
28
+
29
+ # Module-level cache for agents to persist across requests
30
+ _AGENT_CACHE: Dict[ModelName, PydanticAgent] = {}
31
+ _AGENT_CACHE_VERSION: Dict[ModelName, int] = {}
32
+
33
+
34
+ def clear_all_caches():
35
+ """Clear all module-level caches. Useful for testing."""
36
+ _PROMPT_CACHE.clear()
37
+ _TUNACODE_CACHE.clear()
38
+ _AGENT_CACHE.clear()
39
+ _AGENT_CACHE_VERSION.clear()
40
+
23
41
 
24
42
  def get_agent_tool():
25
43
  """Lazy import for Agent and Tool to avoid circular imports."""
@@ -29,43 +47,114 @@ def get_agent_tool():
29
47
 
30
48
 
31
49
  def load_system_prompt(base_path: Path) -> str:
32
- """Load the system prompt from file."""
50
+ """Load the system prompt from file with caching."""
33
51
  prompt_path = base_path / "prompts" / "system.md"
52
+ cache_key = str(prompt_path)
53
+
54
+ # Check cache with file modification time
34
55
  try:
56
+ if cache_key in _PROMPT_CACHE:
57
+ cached_content, cached_mtime = _PROMPT_CACHE[cache_key]
58
+ current_mtime = prompt_path.stat().st_mtime
59
+ if current_mtime == cached_mtime:
60
+ return cached_content
61
+
62
+ # Load from file and cache
35
63
  with open(prompt_path, "r", encoding="utf-8") as f:
36
- return f.read().strip()
64
+ content = f.read().strip()
65
+ _PROMPT_CACHE[cache_key] = (content, prompt_path.stat().st_mtime)
66
+ return content
67
+
37
68
  except FileNotFoundError:
38
69
  # Fallback to system.txt if system.md not found
39
70
  prompt_path = base_path / "prompts" / "system.txt"
71
+ cache_key = str(prompt_path)
72
+
40
73
  try:
74
+ if cache_key in _PROMPT_CACHE:
75
+ cached_content, cached_mtime = _PROMPT_CACHE[cache_key]
76
+ current_mtime = prompt_path.stat().st_mtime
77
+ if current_mtime == cached_mtime:
78
+ return cached_content
79
+
41
80
  with open(prompt_path, "r", encoding="utf-8") as f:
42
- return f.read().strip()
81
+ content = f.read().strip()
82
+ _PROMPT_CACHE[cache_key] = (content, prompt_path.stat().st_mtime)
83
+ return content
84
+
43
85
  except FileNotFoundError:
44
86
  # Use a default system prompt if neither file exists
45
- return "You are a helpful AI assistant for software development tasks."
87
+ return "You are a helpful AI assistant."
46
88
 
47
89
 
48
90
  def load_tunacode_context() -> str:
49
- """Load TUNACODE.md context if it exists."""
91
+ """Load TUNACODE.md context if it exists with caching."""
50
92
  try:
51
93
  tunacode_path = Path.cwd() / "TUNACODE.md"
52
- if tunacode_path.exists():
53
- tunacode_content = tunacode_path.read_text(encoding="utf-8")
54
- if tunacode_content.strip():
55
- logger.info("📄 TUNACODE.md located: Loading context...")
56
- return "\n\n# Project Context from TUNACODE.md\n" + tunacode_content
57
- else:
58
- logger.info("📄 TUNACODE.md not found: Using default context")
94
+ cache_key = str(tunacode_path)
95
+
96
+ if not tunacode_path.exists():
97
+ logger.info("📄 TUNACODE.md not found: Using default context")
98
+ return ""
99
+
100
+ # Check cache with file modification time
101
+ if cache_key in _TUNACODE_CACHE:
102
+ cached_content, cached_mtime = _TUNACODE_CACHE[cache_key]
103
+ current_mtime = tunacode_path.stat().st_mtime
104
+ if current_mtime == cached_mtime:
105
+ return cached_content
106
+
107
+ # Load from file and cache
108
+ tunacode_content = tunacode_path.read_text(encoding="utf-8")
109
+ if tunacode_content.strip():
110
+ logger.info("📄 TUNACODE.md located: Loading context...")
111
+ result = "\n\n# Project Context from TUNACODE.md\n" + tunacode_content
112
+ _TUNACODE_CACHE[cache_key] = (result, tunacode_path.stat().st_mtime)
113
+ return result
59
114
  else:
60
115
  logger.info("📄 TUNACODE.md not found: Using default context")
116
+ _TUNACODE_CACHE[cache_key] = ("", tunacode_path.stat().st_mtime)
117
+ return ""
118
+
61
119
  except Exception as e:
62
120
  logger.debug(f"Error loading TUNACODE.md: {e}")
63
- return ""
121
+ return ""
64
122
 
65
123
 
66
124
  def get_or_create_agent(model: ModelName, state_manager: StateManager) -> PydanticAgent:
67
125
  """Get existing agent or create new one for the specified model."""
68
- if model not in state_manager.session.agents:
126
+ import logging
127
+
128
+ logger = logging.getLogger(__name__)
129
+
130
+ # Check session-level cache first (for backward compatibility with tests)
131
+ if model in state_manager.session.agents:
132
+ logger.debug(f"Using session-cached agent for model {model}")
133
+ return state_manager.session.agents[model]
134
+
135
+ # Check module-level cache
136
+ if model in _AGENT_CACHE:
137
+ # Verify cache is still valid (check for config changes)
138
+ current_version = hash(
139
+ (
140
+ state_manager.is_plan_mode(),
141
+ str(state_manager.session.user_config.get("settings", {}).get("max_retries", 3)),
142
+ str(state_manager.session.user_config.get("mcpServers", {})),
143
+ )
144
+ )
145
+ if _AGENT_CACHE_VERSION.get(model) == current_version:
146
+ logger.debug(f"Using module-cached agent for model {model}")
147
+ state_manager.session.agents[model] = _AGENT_CACHE[model]
148
+ return _AGENT_CACHE[model]
149
+ else:
150
+ logger.debug(f"Cache invalidated for model {model} due to config change")
151
+ del _AGENT_CACHE[model]
152
+ del _AGENT_CACHE_VERSION[model]
153
+
154
+ if model not in _AGENT_CACHE:
155
+ logger.debug(
156
+ f"Creating new agent for model {model}, plan_mode={state_manager.is_plan_mode()}"
157
+ )
69
158
  max_retries = state_manager.session.user_config.get("settings", {}).get("max_retries", 3)
70
159
 
71
160
  # Lazy import Agent and Tool
@@ -78,8 +167,70 @@ def get_or_create_agent(model: ModelName, state_manager: StateManager) -> Pydant
78
167
  # Load TUNACODE.md context
79
168
  system_prompt += load_tunacode_context()
80
169
 
81
- # Initialize todo tool
170
+ # Add plan mode context if in plan mode
171
+ if state_manager.is_plan_mode():
172
+ # REMOVE all TUNACODE_TASK_COMPLETE instructions from the system prompt
173
+ system_prompt = system_prompt.replace(
174
+ "TUNACODE_TASK_COMPLETE", "PLAN_MODE_TASK_PLACEHOLDER"
175
+ )
176
+ # Remove the completion guidance that conflicts with plan mode
177
+ lines_to_remove = [
178
+ "When a task is COMPLETE, start your response with: TUNACODE_TASK_COMPLETE",
179
+ "4. When a task is COMPLETE, start your response with: TUNACODE_TASK_COMPLETE",
180
+ "**How to signal completion:**",
181
+ "TUNACODE_TASK_COMPLETE",
182
+ "[Your summary of what was accomplished]",
183
+ "**IMPORTANT**: Always evaluate if you've completed the task. If yes, use TUNACODE_TASK_COMPLETE.",
184
+ "This prevents wasting iterations and API calls.",
185
+ ]
186
+ for line in lines_to_remove:
187
+ system_prompt = system_prompt.replace(line, "")
188
+ # COMPLETELY REPLACE system prompt in plan mode - nuclear option
189
+ system_prompt = """
190
+ 🔧 PLAN MODE - TOOL EXECUTION ONLY 🔧
191
+
192
+ You are a planning assistant that ONLY communicates through tool execution.
193
+
194
+ CRITICAL: You cannot respond with text. You MUST use tools for everything.
195
+
196
+ AVAILABLE TOOLS:
197
+ - read_file(filepath): Read file contents
198
+ - grep(pattern): Search for text patterns
199
+ - list_dir(directory): List directory contents
200
+ - glob(pattern): Find files matching patterns
201
+ - present_plan(title, overview, steps, files_to_create, success_criteria): Present structured plan
202
+
203
+ MANDATORY WORKFLOW:
204
+ 1. User asks you to plan something
205
+ 2. You research using read-only tools (if needed)
206
+ 3. You EXECUTE present_plan tool with structured data
207
+ 4. DONE
208
+
209
+ FORBIDDEN:
210
+ - Text responses
211
+ - Showing function calls as code
212
+ - Saying "here is the plan"
213
+ - Any text completion
214
+
215
+ EXAMPLE:
216
+ User: "plan a markdown file"
217
+ You: [Call read_file or grep for research if needed]
218
+ [Call present_plan tool with actual parameters - NOT as text]
219
+
220
+ The present_plan tool takes these parameters:
221
+ - title: Brief title string
222
+ - overview: What the plan accomplishes
223
+ - steps: List of implementation steps
224
+ - files_to_create: List of files to create
225
+ - success_criteria: List of success criteria
226
+
227
+ YOU MUST EXECUTE present_plan TOOL TO COMPLETE ANY PLANNING TASK.
228
+ """
229
+
230
+ # Initialize tools that need state manager
82
231
  todo_tool = TodoTool(state_manager=state_manager)
232
+ present_plan = create_present_plan_tool(state_manager)
233
+ logger.debug(f"Tools initialized, present_plan available: {present_plan is not None}")
83
234
 
84
235
  # Add todo context if available
85
236
  try:
@@ -89,12 +240,21 @@ def get_or_create_agent(model: ModelName, state_manager: StateManager) -> Pydant
89
240
  except Exception as e:
90
241
  logger.warning(f"Warning: Failed to load todos: {e}")
91
242
 
92
- # Create agent with all tools
93
- state_manager.session.agents[model] = Agent(
94
- model=model,
95
- system_prompt=system_prompt,
96
- tools=[
243
+ # Create tool list based on mode
244
+ if state_manager.is_plan_mode():
245
+ # Plan mode: Only read-only tools + present_plan
246
+ tools_list = [
247
+ Tool(present_plan, max_retries=max_retries),
248
+ Tool(glob, max_retries=max_retries),
249
+ Tool(grep, max_retries=max_retries),
250
+ Tool(list_dir, max_retries=max_retries),
251
+ Tool(read_file, max_retries=max_retries),
252
+ ]
253
+ else:
254
+ # Normal mode: All tools
255
+ tools_list = [
97
256
  Tool(bash, max_retries=max_retries),
257
+ Tool(present_plan, max_retries=max_retries),
98
258
  Tool(glob, max_retries=max_retries),
99
259
  Tool(grep, max_retries=max_retries),
100
260
  Tool(list_dir, max_retries=max_retries),
@@ -103,7 +263,37 @@ def get_or_create_agent(model: ModelName, state_manager: StateManager) -> Pydant
103
263
  Tool(todo_tool._execute, max_retries=max_retries),
104
264
  Tool(update_file, max_retries=max_retries),
105
265
  Tool(write_file, max_retries=max_retries),
106
- ],
266
+ ]
267
+
268
+ # Log which tools are being registered
269
+ logger.debug(
270
+ f"Creating agent: plan_mode={state_manager.is_plan_mode()}, tools={len(tools_list)}"
271
+ )
272
+ if state_manager.is_plan_mode():
273
+ logger.debug(f"PLAN MODE TOOLS: {[str(tool) for tool in tools_list]}")
274
+ logger.debug(f"present_plan tool type: {type(present_plan)}")
275
+
276
+ if "PLAN MODE - YOU MUST USE THE present_plan TOOL" in system_prompt:
277
+ logger.debug("✅ Plan mode instructions ARE in system prompt")
278
+ else:
279
+ logger.debug("❌ Plan mode instructions NOT in system prompt")
280
+
281
+ agent = Agent(
282
+ model=model,
283
+ system_prompt=system_prompt,
284
+ tools=tools_list,
107
285
  mcp_servers=get_mcp_servers(state_manager),
108
286
  )
109
- return state_manager.session.agents[model]
287
+
288
+ # Store in both caches
289
+ _AGENT_CACHE[model] = agent
290
+ _AGENT_CACHE_VERSION[model] = hash(
291
+ (
292
+ state_manager.is_plan_mode(),
293
+ str(state_manager.session.user_config.get("settings", {}).get("max_retries", 3)),
294
+ str(state_manager.session.user_config.get("mcpServers", {})),
295
+ )
296
+ )
297
+ state_manager.session.agents[model] = agent
298
+
299
+ return _AGENT_CACHE[model]