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
@@ -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
 
@@ -29,22 +29,25 @@ async def display_agent_output(res, enable_streaming: bool, state_manager=None)
29
29
 
30
30
  if '"tool_uses"' in output:
31
31
  return
32
-
32
+
33
33
  # In plan mode, don't display any agent text output at all
34
34
  # The plan will be displayed via the present_plan tool
35
35
  if state_manager and state_manager.is_plan_mode():
36
36
  return
37
-
37
+
38
38
  # Filter out plan mode system prompts and tool definitions
39
- if any(phrase in output for phrase in [
40
- "PLAN MODE - TOOL EXECUTION ONLY",
41
- "🔧 PLAN MODE",
42
- "TOOL EXECUTION ONLY 🔧",
43
- "planning assistant that ONLY communicates",
44
- "namespace functions {",
45
- "namespace multi_tool_use {",
46
- "You are trained on data up to"
47
- ]):
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
+ ):
48
51
  return
49
52
 
50
53
  await ui.agent(output)
@@ -62,12 +62,15 @@ async def tool_handler(part, state_manager: StateManager):
62
62
  # Check if tool is blocked in plan mode first
63
63
  if tool_handler_instance.is_tool_blocked_in_plan_mode(part.tool_name):
64
64
  from tunacode.constants import READ_ONLY_TOOLS
65
- error_msg = (f"🔍 Plan Mode: Tool '{part.tool_name}' is not available in Plan Mode.\n"
66
- f"Only read-only tools are allowed: {', '.join(READ_ONLY_TOOLS)}\n"
67
- f"Use 'exit_plan_mode' tool to present your plan and exit Plan Mode.")
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
+ )
68
71
  print(f"\n❌ {error_msg}\n")
69
72
  return True # Abort the tool
70
-
73
+
71
74
  if not tool_handler_instance.should_confirm(part.tool_name):
72
75
  return False
73
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.56"
12
+ APP_VERSION = "0.0.57"
13
13
 
14
14
 
15
15
  # File patterns
@@ -60,7 +60,13 @@ TOOL_TODO = ToolName.TODO
60
60
  TOOL_EXIT_PLAN_MODE = ToolName.EXIT_PLAN_MODE
61
61
 
62
62
  # Tool categorization
63
- READ_ONLY_TOOLS = [ToolName.READ_FILE, ToolName.GREP, ToolName.LIST_DIR, ToolName.GLOB, ToolName.EXIT_PLAN_MODE]
63
+ READ_ONLY_TOOLS = [
64
+ ToolName.READ_FILE,
65
+ ToolName.GREP,
66
+ ToolName.LIST_DIR,
67
+ ToolName.GLOB,
68
+ ToolName.EXIT_PLAN_MODE,
69
+ ]
64
70
  WRITE_TOOLS = [ToolName.WRITE_FILE, ToolName.UPDATE_FILE]
65
71
  EXECUTE_TOOLS = [ToolName.BASH, ToolName.RUN_COMMAND]
66
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
 
@@ -8,10 +9,10 @@ from tunacode.core.logging.logger import get_logger
8
9
  from tunacode.core.state import StateManager
9
10
  from tunacode.services.mcp import get_mcp_servers
10
11
  from tunacode.tools.bash import bash
11
- from tunacode.tools.present_plan import create_present_plan_tool
12
12
  from tunacode.tools.glob import glob
13
13
  from tunacode.tools.grep import grep
14
14
  from tunacode.tools.list_dir import list_dir
15
+ from tunacode.tools.present_plan import create_present_plan_tool
15
16
  from tunacode.tools.read_file import read_file
16
17
  from tunacode.tools.run_command import run_command
17
18
  from tunacode.tools.todo import TodoTool
@@ -21,6 +22,22 @@ from tunacode.types import ModelName, PydanticAgent
21
22
 
22
23
  logger = get_logger(__name__)
23
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
+
24
41
 
25
42
  def get_agent_tool():
26
43
  """Lazy import for Agent and Tool to avoid circular imports."""
@@ -30,47 +47,114 @@ def get_agent_tool():
30
47
 
31
48
 
32
49
  def load_system_prompt(base_path: Path) -> str:
33
- """Load the system prompt from file."""
50
+ """Load the system prompt from file with caching."""
34
51
  prompt_path = base_path / "prompts" / "system.md"
52
+ cache_key = str(prompt_path)
53
+
54
+ # Check cache with file modification time
35
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
36
63
  with open(prompt_path, "r", encoding="utf-8") as f:
37
- return f.read().strip()
64
+ content = f.read().strip()
65
+ _PROMPT_CACHE[cache_key] = (content, prompt_path.stat().st_mtime)
66
+ return content
67
+
38
68
  except FileNotFoundError:
39
69
  # Fallback to system.txt if system.md not found
40
70
  prompt_path = base_path / "prompts" / "system.txt"
71
+ cache_key = str(prompt_path)
72
+
41
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
+
42
80
  with open(prompt_path, "r", encoding="utf-8") as f:
43
- return f.read().strip()
81
+ content = f.read().strip()
82
+ _PROMPT_CACHE[cache_key] = (content, prompt_path.stat().st_mtime)
83
+ return content
84
+
44
85
  except FileNotFoundError:
45
86
  # Use a default system prompt if neither file exists
46
- return "You are a helpful AI assistant for software development tasks."
87
+ return "You are a helpful AI assistant."
47
88
 
48
89
 
49
90
  def load_tunacode_context() -> str:
50
- """Load TUNACODE.md context if it exists."""
91
+ """Load TUNACODE.md context if it exists with caching."""
51
92
  try:
52
93
  tunacode_path = Path.cwd() / "TUNACODE.md"
53
- if tunacode_path.exists():
54
- tunacode_content = tunacode_path.read_text(encoding="utf-8")
55
- if tunacode_content.strip():
56
- logger.info("📄 TUNACODE.md located: Loading context...")
57
- return "\n\n# Project Context from TUNACODE.md\n" + tunacode_content
58
- else:
59
- 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
60
114
  else:
61
115
  logger.info("📄 TUNACODE.md not found: Using default context")
116
+ _TUNACODE_CACHE[cache_key] = ("", tunacode_path.stat().st_mtime)
117
+ return ""
118
+
62
119
  except Exception as e:
63
120
  logger.debug(f"Error loading TUNACODE.md: {e}")
64
- return ""
121
+ return ""
65
122
 
66
123
 
67
124
  def get_or_create_agent(model: ModelName, state_manager: StateManager) -> PydanticAgent:
68
125
  """Get existing agent or create new one for the specified model."""
69
126
  import logging
127
+
70
128
  logger = logging.getLogger(__name__)
71
-
72
- if model not in state_manager.session.agents:
73
- logger.debug(f"Creating new agent for model {model}, plan_mode={state_manager.is_plan_mode()}")
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
+ )
74
158
  max_retries = state_manager.session.user_config.get("settings", {}).get("max_retries", 3)
75
159
 
76
160
  # Lazy import Agent and Tool
@@ -86,56 +170,21 @@ def get_or_create_agent(model: ModelName, state_manager: StateManager) -> Pydant
86
170
  # Add plan mode context if in plan mode
87
171
  if state_manager.is_plan_mode():
88
172
  # REMOVE all TUNACODE_TASK_COMPLETE instructions from the system prompt
89
- system_prompt = system_prompt.replace("TUNACODE_TASK_COMPLETE", "PLAN_MODE_TASK_PLACEHOLDER")
173
+ system_prompt = system_prompt.replace(
174
+ "TUNACODE_TASK_COMPLETE", "PLAN_MODE_TASK_PLACEHOLDER"
175
+ )
90
176
  # Remove the completion guidance that conflicts with plan mode
91
177
  lines_to_remove = [
92
178
  "When a task is COMPLETE, start your response with: TUNACODE_TASK_COMPLETE",
93
- "4. 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",
94
180
  "**How to signal completion:**",
95
181
  "TUNACODE_TASK_COMPLETE",
96
182
  "[Your summary of what was accomplished]",
97
183
  "**IMPORTANT**: Always evaluate if you've completed the task. If yes, use TUNACODE_TASK_COMPLETE.",
98
- "This prevents wasting iterations and API calls."
184
+ "This prevents wasting iterations and API calls.",
99
185
  ]
100
186
  for line in lines_to_remove:
101
187
  system_prompt = system_prompt.replace(line, "")
102
- plan_mode_override = """
103
- 🔍 PLAN MODE - YOU MUST USE THE present_plan TOOL 🔍
104
-
105
- CRITICAL: You are in Plan Mode. You MUST execute the present_plan TOOL, not show it as text.
106
-
107
- ❌ WRONG - DO NOT SHOW THE FUNCTION AS TEXT:
108
- ```
109
- present_plan(title="...", ...) # THIS IS WRONG - DON'T SHOW AS CODE
110
- ```
111
-
112
- ✅ CORRECT - ACTUALLY EXECUTE THE TOOL:
113
- You must EXECUTE present_plan as a tool call, just like you execute read_file or grep.
114
-
115
- CRITICAL RULES:
116
- 1. DO NOT show present_plan() as code or text
117
- 2. DO NOT write "Here's the plan" or any text description
118
- 3. DO NOT use TUNACODE_TASK_COMPLETE
119
- 4. DO NOT use markdown code blocks for present_plan
120
-
121
- YOU MUST EXECUTE THE TOOL:
122
- When the user asks you to "plan" something, you must:
123
- 1. Research using read_only tools (optional)
124
- 2. EXECUTE present_plan tool with the plan data
125
- 3. The tool will handle displaying the plan
126
-
127
- Example of CORRECT behavior:
128
- User: "plan a markdown file"
129
- You: [Execute read_file/grep if needed for research]
130
- [Then EXECUTE present_plan tool - not as text but as an actual tool call]
131
-
132
- Remember: present_plan is a TOOL like read_file or grep. You must EXECUTE it, not SHOW it.
133
-
134
- Available tools:
135
- - read_file, grep, list_dir, glob: For research
136
- - present_plan: EXECUTE this tool to present the plan (DO NOT show as text)
137
-
138
- """
139
188
  # COMPLETELY REPLACE system prompt in plan mode - nuclear option
140
189
  system_prompt = """
141
190
  🔧 PLAN MODE - TOOL EXECUTION ONLY 🔧
@@ -146,7 +195,7 @@ CRITICAL: You cannot respond with text. You MUST use tools for everything.
146
195
 
147
196
  AVAILABLE TOOLS:
148
197
  - read_file(filepath): Read file contents
149
- - grep(pattern): Search for text patterns
198
+ - grep(pattern): Search for text patterns
150
199
  - list_dir(directory): List directory contents
151
200
  - glob(pattern): Find files matching patterns
152
201
  - present_plan(title, overview, steps, files_to_create, success_criteria): Present structured plan
@@ -170,7 +219,7 @@ You: [Call read_file or grep for research if needed]
170
219
 
171
220
  The present_plan tool takes these parameters:
172
221
  - title: Brief title string
173
- - overview: What the plan accomplishes
222
+ - overview: What the plan accomplishes
174
223
  - steps: List of implementation steps
175
224
  - files_to_create: List of files to create
176
225
  - success_criteria: List of success criteria
@@ -215,22 +264,36 @@ YOU MUST EXECUTE present_plan TOOL TO COMPLETE ANY PLANNING TASK.
215
264
  Tool(update_file, max_retries=max_retries),
216
265
  Tool(write_file, max_retries=max_retries),
217
266
  ]
218
-
267
+
219
268
  # Log which tools are being registered
220
- logger.debug(f"Creating agent: plan_mode={state_manager.is_plan_mode()}, tools={len(tools_list)}")
269
+ logger.debug(
270
+ f"Creating agent: plan_mode={state_manager.is_plan_mode()}, tools={len(tools_list)}"
271
+ )
221
272
  if state_manager.is_plan_mode():
222
273
  logger.debug(f"PLAN MODE TOOLS: {[str(tool) for tool in tools_list]}")
223
274
  logger.debug(f"present_plan tool type: {type(present_plan)}")
224
-
275
+
225
276
  if "PLAN MODE - YOU MUST USE THE present_plan TOOL" in system_prompt:
226
277
  logger.debug("✅ Plan mode instructions ARE in system prompt")
227
278
  else:
228
279
  logger.debug("❌ Plan mode instructions NOT in system prompt")
229
-
230
- state_manager.session.agents[model] = Agent(
280
+
281
+ agent = Agent(
231
282
  model=model,
232
283
  system_prompt=system_prompt,
233
284
  tools=tools_list,
234
285
  mcp_servers=get_mcp_servers(state_manager),
235
286
  )
236
- 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]
@@ -366,7 +366,9 @@ async def _process_tool_calls(
366
366
  buffered_part.tool_name == "grep"
367
367
  and "pattern" in buffered_part.args
368
368
  ):
369
- tool_desc += f" → pattern: '{buffered_part.args['pattern']}'"
369
+ tool_desc += (
370
+ f" → pattern: '{buffered_part.args['pattern']}'"
371
+ )
370
372
  if "include_files" in buffered_part.args:
371
373
  tool_desc += (
372
374
  f", files: '{buffered_part.args['include_files']}'"
@@ -380,7 +382,9 @@ async def _process_tool_calls(
380
382
  buffered_part.tool_name == "glob"
381
383
  and "pattern" in buffered_part.args
382
384
  ):
383
- tool_desc += f" → pattern: '{buffered_part.args['pattern']}'"
385
+ tool_desc += (
386
+ f" → pattern: '{buffered_part.args['pattern']}'"
387
+ )
384
388
  await ui.muted(tool_desc)
385
389
  await ui.muted("=" * 60)
386
390