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.
- tunacode/cli/commands/implementations/plan.py +50 -0
- tunacode/cli/commands/registry.py +3 -0
- tunacode/cli/repl.py +327 -186
- tunacode/cli/repl_components/command_parser.py +37 -4
- tunacode/cli/repl_components/error_recovery.py +79 -1
- tunacode/cli/repl_components/output_display.py +21 -1
- tunacode/cli/repl_components/tool_executor.py +12 -0
- tunacode/configuration/defaults.py +8 -0
- tunacode/constants.py +10 -2
- tunacode/core/agents/agent_components/agent_config.py +212 -22
- tunacode/core/agents/agent_components/node_processor.py +46 -40
- tunacode/core/code_index.py +83 -29
- tunacode/core/state.py +44 -0
- tunacode/core/token_usage/usage_tracker.py +2 -2
- tunacode/core/tool_handler.py +20 -0
- tunacode/prompts/system.md +117 -490
- tunacode/services/mcp.py +29 -7
- tunacode/tools/base.py +110 -0
- tunacode/tools/bash.py +96 -1
- tunacode/tools/exit_plan_mode.py +273 -0
- tunacode/tools/glob.py +366 -33
- tunacode/tools/grep.py +226 -77
- tunacode/tools/grep_components/result_formatter.py +98 -4
- tunacode/tools/list_dir.py +132 -2
- tunacode/tools/present_plan.py +288 -0
- tunacode/tools/read_file.py +91 -0
- tunacode/tools/run_command.py +99 -0
- tunacode/tools/schema_assembler.py +167 -0
- tunacode/tools/todo.py +108 -1
- tunacode/tools/update_file.py +94 -0
- tunacode/tools/write_file.py +86 -0
- tunacode/types.py +58 -0
- tunacode/ui/input.py +14 -2
- tunacode/ui/keybindings.py +25 -4
- tunacode/ui/panels.py +53 -8
- tunacode/ui/prompt_manager.py +25 -2
- tunacode/ui/tool_ui.py +3 -2
- tunacode/utils/json_utils.py +206 -0
- tunacode/utils/message_utils.py +14 -4
- tunacode/utils/ripgrep.py +332 -9
- {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.57.dist-info}/METADATA +8 -3
- {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.57.dist-info}/RECORD +46 -42
- tunacode/tools/read_file_async_poc.py +0 -196
- {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.57.dist-info}/WHEEL +0 -0
- {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.57.dist-info}/entry_points.txt +0 -0
- {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.57.dist-info}/licenses/LICENSE +0 -0
- {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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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.
|
|
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 = [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
93
|
-
state_manager.
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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]
|