tunacode-cli 0.0.50__py3-none-any.whl → 0.0.53__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/base.py +2 -2
- tunacode/cli/commands/implementations/__init__.py +7 -1
- tunacode/cli/commands/implementations/conversation.py +1 -1
- tunacode/cli/commands/implementations/debug.py +1 -1
- tunacode/cli/commands/implementations/development.py +4 -1
- tunacode/cli/commands/implementations/template.py +132 -0
- tunacode/cli/commands/registry.py +28 -1
- tunacode/cli/commands/template_shortcut.py +93 -0
- tunacode/cli/main.py +6 -0
- tunacode/cli/repl.py +29 -174
- tunacode/cli/repl_components/__init__.py +10 -0
- tunacode/cli/repl_components/command_parser.py +34 -0
- tunacode/cli/repl_components/error_recovery.py +88 -0
- tunacode/cli/repl_components/output_display.py +33 -0
- tunacode/cli/repl_components/tool_executor.py +84 -0
- tunacode/configuration/defaults.py +2 -2
- tunacode/configuration/settings.py +11 -14
- tunacode/constants.py +57 -23
- tunacode/context.py +0 -14
- tunacode/core/agents/agent_components/__init__.py +27 -0
- tunacode/core/agents/agent_components/agent_config.py +109 -0
- tunacode/core/agents/agent_components/json_tool_parser.py +109 -0
- tunacode/core/agents/agent_components/message_handler.py +100 -0
- tunacode/core/agents/agent_components/node_processor.py +480 -0
- tunacode/core/agents/agent_components/response_state.py +13 -0
- tunacode/core/agents/agent_components/result_wrapper.py +50 -0
- tunacode/core/agents/agent_components/task_completion.py +28 -0
- tunacode/core/agents/agent_components/tool_buffer.py +24 -0
- tunacode/core/agents/agent_components/tool_executor.py +49 -0
- tunacode/core/agents/main.py +421 -778
- tunacode/core/agents/utils.py +42 -2
- tunacode/core/background/manager.py +3 -3
- tunacode/core/logging/__init__.py +4 -3
- tunacode/core/logging/config.py +29 -16
- tunacode/core/logging/formatters.py +1 -1
- tunacode/core/logging/handlers.py +41 -7
- tunacode/core/setup/__init__.py +2 -0
- tunacode/core/setup/agent_setup.py +2 -2
- tunacode/core/setup/base.py +2 -2
- tunacode/core/setup/config_setup.py +10 -6
- tunacode/core/setup/git_safety_setup.py +13 -2
- tunacode/core/setup/template_setup.py +75 -0
- tunacode/core/state.py +13 -2
- tunacode/core/token_usage/api_response_parser.py +6 -2
- tunacode/core/token_usage/usage_tracker.py +37 -7
- tunacode/core/tool_handler.py +24 -1
- tunacode/prompts/system.md +289 -4
- tunacode/setup.py +2 -0
- tunacode/templates/__init__.py +9 -0
- tunacode/templates/loader.py +210 -0
- tunacode/tools/glob.py +3 -3
- tunacode/tools/grep.py +26 -276
- tunacode/tools/grep_components/__init__.py +9 -0
- tunacode/tools/grep_components/file_filter.py +93 -0
- tunacode/tools/grep_components/pattern_matcher.py +152 -0
- tunacode/tools/grep_components/result_formatter.py +45 -0
- tunacode/tools/grep_components/search_result.py +35 -0
- tunacode/tools/todo.py +27 -21
- tunacode/types.py +19 -4
- tunacode/ui/completers.py +6 -1
- tunacode/ui/decorators.py +2 -2
- tunacode/ui/keybindings.py +1 -1
- tunacode/ui/panels.py +13 -5
- tunacode/ui/prompt_manager.py +1 -1
- tunacode/ui/tool_ui.py +8 -2
- tunacode/utils/bm25.py +4 -4
- tunacode/utils/file_utils.py +2 -2
- tunacode/utils/message_utils.py +3 -1
- tunacode/utils/system.py +0 -4
- tunacode/utils/text_utils.py +1 -1
- tunacode/utils/token_counter.py +2 -2
- {tunacode_cli-0.0.50.dist-info → tunacode_cli-0.0.53.dist-info}/METADATA +146 -1
- tunacode_cli-0.0.53.dist-info/RECORD +123 -0
- {tunacode_cli-0.0.50.dist-info → tunacode_cli-0.0.53.dist-info}/top_level.txt +0 -1
- api/auth.py +0 -13
- api/users.py +0 -8
- tunacode/core/recursive/__init__.py +0 -18
- tunacode/core/recursive/aggregator.py +0 -467
- tunacode/core/recursive/budget.py +0 -414
- tunacode/core/recursive/decomposer.py +0 -398
- tunacode/core/recursive/executor.py +0 -470
- tunacode/core/recursive/hierarchy.py +0 -488
- tunacode/ui/recursive_progress.py +0 -380
- tunacode_cli-0.0.50.dist-info/RECORD +0 -107
- {tunacode_cli-0.0.50.dist-info → tunacode_cli-0.0.53.dist-info}/WHEEL +0 -0
- {tunacode_cli-0.0.50.dist-info → tunacode_cli-0.0.53.dist-info}/entry_points.txt +0 -0
- {tunacode_cli-0.0.50.dist-info → tunacode_cli-0.0.53.dist-info}/licenses/LICENSE +0 -0
tunacode/core/agents/main.py
CHANGED
|
@@ -4,24 +4,61 @@ Main agent functionality and coordination for the TunaCode CLI.
|
|
|
4
4
|
Handles agent creation, configuration, and request processing.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
import
|
|
9
|
-
|
|
10
|
-
import
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
7
|
+
# Re-export for backward compatibility
|
|
8
|
+
from tunacode.services.mcp import get_mcp_servers
|
|
9
|
+
|
|
10
|
+
from .agent_components import (
|
|
11
|
+
ToolBuffer,
|
|
12
|
+
check_task_completion,
|
|
13
|
+
extract_and_execute_tool_calls,
|
|
14
|
+
get_model_messages,
|
|
15
|
+
parse_json_tool_calls,
|
|
16
|
+
patch_tool_messages,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"ToolBuffer",
|
|
21
|
+
"check_task_completion",
|
|
22
|
+
"extract_and_execute_tool_calls",
|
|
23
|
+
"get_model_messages",
|
|
24
|
+
"parse_json_tool_calls",
|
|
25
|
+
"patch_tool_messages",
|
|
26
|
+
"get_mcp_servers",
|
|
27
|
+
"check_query_satisfaction",
|
|
28
|
+
"process_request",
|
|
29
|
+
"get_or_create_agent",
|
|
30
|
+
"_process_node",
|
|
31
|
+
"ResponseState",
|
|
32
|
+
"SimpleResult",
|
|
33
|
+
"AgentRunWrapper",
|
|
34
|
+
"AgentRunWithState",
|
|
35
|
+
"execute_tools_parallel",
|
|
36
|
+
"get_agent_tool",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
from typing import TYPE_CHECKING, Awaitable, Callable, Optional
|
|
14
40
|
|
|
15
41
|
from pydantic_ai import Agent
|
|
16
42
|
|
|
43
|
+
if TYPE_CHECKING:
|
|
44
|
+
from pydantic_ai import Tool # noqa: F401
|
|
45
|
+
|
|
17
46
|
from tunacode.core.logging.logger import get_logger
|
|
18
47
|
|
|
48
|
+
# Import agent components
|
|
49
|
+
from .agent_components import (
|
|
50
|
+
AgentRunWithState,
|
|
51
|
+
AgentRunWrapper,
|
|
52
|
+
ResponseState,
|
|
53
|
+
SimpleResult,
|
|
54
|
+
_process_node,
|
|
55
|
+
execute_tools_parallel,
|
|
56
|
+
get_or_create_agent,
|
|
57
|
+
)
|
|
58
|
+
|
|
19
59
|
# Import streaming types with fallback for older versions
|
|
20
60
|
try:
|
|
21
|
-
from pydantic_ai.messages import
|
|
22
|
-
PartDeltaEvent,
|
|
23
|
-
TextPartDelta,
|
|
24
|
-
)
|
|
61
|
+
from pydantic_ai.messages import PartDeltaEvent, TextPartDelta
|
|
25
62
|
|
|
26
63
|
STREAMING_AVAILABLE = True
|
|
27
64
|
except ImportError:
|
|
@@ -30,826 +67,449 @@ except ImportError:
|
|
|
30
67
|
TextPartDelta = None
|
|
31
68
|
STREAMING_AVAILABLE = False
|
|
32
69
|
|
|
33
|
-
from tunacode.constants import READ_ONLY_TOOLS
|
|
34
70
|
from tunacode.core.state import StateManager
|
|
35
|
-
from tunacode.core.token_usage.api_response_parser import ApiResponseParser
|
|
36
|
-
from tunacode.core.token_usage.cost_calculator import CostCalculator
|
|
37
71
|
from tunacode.exceptions import ToolBatchingJSONError, UserAbortError
|
|
38
|
-
from tunacode.services.mcp import get_mcp_servers
|
|
39
|
-
from tunacode.tools.bash import bash
|
|
40
|
-
from tunacode.tools.glob import glob
|
|
41
|
-
from tunacode.tools.grep import grep
|
|
42
|
-
from tunacode.tools.list_dir import list_dir
|
|
43
|
-
from tunacode.tools.read_file import read_file
|
|
44
|
-
from tunacode.tools.run_command import run_command
|
|
45
|
-
from tunacode.tools.todo import TodoTool
|
|
46
|
-
from tunacode.tools.update_file import update_file
|
|
47
|
-
from tunacode.tools.write_file import write_file
|
|
48
72
|
from tunacode.types import (
|
|
49
73
|
AgentRun,
|
|
50
|
-
ErrorMessage,
|
|
51
74
|
FallbackResponse,
|
|
52
75
|
ModelName,
|
|
53
|
-
PydanticAgent,
|
|
54
|
-
ResponseState,
|
|
55
|
-
SimpleResult,
|
|
56
76
|
ToolCallback,
|
|
57
|
-
ToolCallId,
|
|
58
|
-
ToolName,
|
|
59
77
|
UsageTrackerProtocol,
|
|
60
78
|
)
|
|
61
79
|
|
|
62
80
|
# Configure logging
|
|
63
81
|
logger = get_logger(__name__)
|
|
64
82
|
|
|
83
|
+
# Cache for UserPromptPart class to avoid repeated imports
|
|
84
|
+
_USER_PROMPT_PART_CLASS = None
|
|
65
85
|
|
|
66
|
-
class ToolBuffer:
|
|
67
|
-
"""Buffer for collecting read-only tool calls to execute in parallel."""
|
|
68
86
|
|
|
69
|
-
|
|
70
|
-
|
|
87
|
+
def _get_user_prompt_part_class():
|
|
88
|
+
"""Get UserPromptPart class with caching and fallback for test environment.
|
|
71
89
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
90
|
+
This function follows DRY principle by centralizing the UserPromptPart
|
|
91
|
+
import logic and caching the result to avoid repeated imports.
|
|
92
|
+
"""
|
|
93
|
+
global _USER_PROMPT_PART_CLASS
|
|
75
94
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
tasks = self.read_only_tasks
|
|
79
|
-
self.read_only_tasks = []
|
|
80
|
-
return tasks
|
|
95
|
+
if _USER_PROMPT_PART_CLASS is not None:
|
|
96
|
+
return _USER_PROMPT_PART_CLASS
|
|
81
97
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
return len(self.read_only_tasks) > 0
|
|
98
|
+
try:
|
|
99
|
+
import importlib
|
|
85
100
|
|
|
101
|
+
messages = importlib.import_module("pydantic_ai.messages")
|
|
102
|
+
_USER_PROMPT_PART_CLASS = getattr(messages, "UserPromptPart", None)
|
|
86
103
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
104
|
+
if _USER_PROMPT_PART_CLASS is None:
|
|
105
|
+
# Fallback for test environment
|
|
106
|
+
class UserPromptPartFallback:
|
|
107
|
+
def __init__(self, content, part_kind):
|
|
108
|
+
self.content = content
|
|
109
|
+
self.part_kind = part_kind
|
|
90
110
|
|
|
91
|
-
|
|
92
|
-
|
|
111
|
+
_USER_PROMPT_PART_CLASS = UserPromptPartFallback
|
|
112
|
+
except Exception:
|
|
113
|
+
# Fallback for test environment
|
|
114
|
+
class UserPromptPartFallback:
|
|
115
|
+
def __init__(self, content, part_kind):
|
|
116
|
+
self.content = content
|
|
117
|
+
self.part_kind = part_kind
|
|
93
118
|
|
|
119
|
+
_USER_PROMPT_PART_CLASS = UserPromptPartFallback
|
|
94
120
|
|
|
95
|
-
|
|
96
|
-
import importlib
|
|
121
|
+
return _USER_PROMPT_PART_CLASS
|
|
97
122
|
|
|
98
|
-
messages = importlib.import_module("pydantic_ai.messages")
|
|
99
|
-
return messages.ModelRequest, messages.ToolReturnPart
|
|
100
123
|
|
|
124
|
+
def get_agent_tool() -> tuple[type[Agent], type["Tool"]]:
|
|
125
|
+
"""Lazy import for Agent and Tool to avoid circular imports."""
|
|
126
|
+
from pydantic_ai import Agent, Tool
|
|
101
127
|
|
|
102
|
-
|
|
103
|
-
tool_calls: List[Tuple[Any, Any]], callback: ToolCallback, return_exceptions: bool = True
|
|
104
|
-
) -> List[Any]:
|
|
105
|
-
"""
|
|
106
|
-
Execute multiple tool calls in parallel using asyncio.
|
|
128
|
+
return Agent, Tool
|
|
107
129
|
|
|
108
|
-
Args:
|
|
109
|
-
tool_calls: List of (part, node) tuples
|
|
110
|
-
callback: The tool callback function to execute
|
|
111
|
-
return_exceptions: Whether to return exceptions or raise them
|
|
112
130
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
async def execute_with_error_handling(part, node):
|
|
120
|
-
try:
|
|
121
|
-
return await callback(part, node)
|
|
122
|
-
except Exception as e:
|
|
123
|
-
logger.error(f"Error executing parallel tool: {e}", exc_info=True)
|
|
124
|
-
return e
|
|
125
|
-
|
|
126
|
-
# If we have more tools than max_parallel, execute in batches
|
|
127
|
-
if len(tool_calls) > max_parallel:
|
|
128
|
-
results = []
|
|
129
|
-
for i in range(0, len(tool_calls), max_parallel):
|
|
130
|
-
batch = tool_calls[i : i + max_parallel]
|
|
131
|
-
batch_tasks = [execute_with_error_handling(part, node) for part, node in batch]
|
|
132
|
-
batch_results = await asyncio.gather(*batch_tasks, return_exceptions=return_exceptions)
|
|
133
|
-
results.extend(batch_results)
|
|
134
|
-
return results
|
|
135
|
-
else:
|
|
136
|
-
tasks = [execute_with_error_handling(part, node) for part, node in tool_calls]
|
|
137
|
-
return await asyncio.gather(*tasks, return_exceptions=return_exceptions)
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
def batch_read_only_tools(tool_calls: List[Any]) -> Iterator[List[Any]]:
|
|
131
|
+
async def check_query_satisfaction(
|
|
132
|
+
agent: Agent,
|
|
133
|
+
original_query: str,
|
|
134
|
+
response: str,
|
|
135
|
+
state_manager: StateManager,
|
|
136
|
+
) -> bool:
|
|
141
137
|
"""
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
Yields batches where:
|
|
145
|
-
- Read-only tools are grouped together
|
|
146
|
-
- Write/execute tools are in their own batch (single item)
|
|
147
|
-
- Order within each batch is preserved
|
|
138
|
+
Check if the response satisfies the original query.
|
|
148
139
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
Yields:
|
|
153
|
-
Batches of tool calls
|
|
140
|
+
Returns:
|
|
141
|
+
bool: True if query is satisfied, False otherwise
|
|
154
142
|
"""
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
current_batch = []
|
|
159
|
-
|
|
160
|
-
for tool_call in tool_calls:
|
|
161
|
-
tool_name = tool_call.tool_name if hasattr(tool_call, "tool_name") else None
|
|
162
|
-
|
|
163
|
-
if tool_name in READ_ONLY_TOOLS:
|
|
164
|
-
# Add to current batch
|
|
165
|
-
current_batch.append(tool_call)
|
|
166
|
-
else:
|
|
167
|
-
# Yield any pending read-only batch
|
|
168
|
-
if current_batch:
|
|
169
|
-
yield current_batch
|
|
170
|
-
current_batch = []
|
|
143
|
+
# For now, always return True to avoid recursive checks
|
|
144
|
+
# The agent should use TUNACODE_TASK_COMPLETE marker instead
|
|
145
|
+
return True
|
|
171
146
|
|
|
172
|
-
# Yield write/execute tool as single-item batch
|
|
173
|
-
yield [tool_call]
|
|
174
147
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
148
|
+
async def process_request(
|
|
149
|
+
message: str,
|
|
150
|
+
model: ModelName,
|
|
151
|
+
state_manager: StateManager,
|
|
152
|
+
tool_callback: Optional[ToolCallback] = None,
|
|
153
|
+
streaming_callback: Optional[Callable[[str], Awaitable[None]]] = None,
|
|
154
|
+
usage_tracker: Optional[UsageTrackerProtocol] = None,
|
|
155
|
+
fallback_enabled: bool = True,
|
|
156
|
+
) -> AgentRun:
|
|
183
157
|
"""
|
|
184
|
-
|
|
158
|
+
Process a single request to the agent.
|
|
185
159
|
|
|
186
160
|
Args:
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
state_manager:
|
|
161
|
+
message: The user's request
|
|
162
|
+
model: The model to use
|
|
163
|
+
state_manager: State manager instance
|
|
164
|
+
tool_callback: Optional callback for tool execution
|
|
165
|
+
streaming_callback: Optional callback for streaming responses
|
|
166
|
+
usage_tracker: Optional usage tracker
|
|
167
|
+
fallback_enabled: Whether to enable fallback responses
|
|
190
168
|
|
|
191
169
|
Returns:
|
|
192
|
-
|
|
170
|
+
AgentRun or wrapper with result
|
|
193
171
|
"""
|
|
172
|
+
# Get or create agent for the model
|
|
173
|
+
agent = get_or_create_agent(model, state_manager)
|
|
194
174
|
|
|
195
|
-
|
|
196
|
-
|
|
175
|
+
# Create a unique request ID for debugging
|
|
176
|
+
import uuid
|
|
197
177
|
|
|
198
|
-
|
|
199
|
-
# Buffer read-only tools
|
|
200
|
-
buffer.add(part, node)
|
|
201
|
-
# Don't execute yet - will be executed in parallel batch
|
|
202
|
-
return None
|
|
178
|
+
request_id = str(uuid.uuid4())[:8]
|
|
203
179
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
180
|
+
# Reset state for new request
|
|
181
|
+
state_manager.session.current_iteration = 0
|
|
182
|
+
state_manager.session.iteration_count = 0
|
|
183
|
+
state_manager.session.tool_calls = []
|
|
207
184
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
185
|
+
# Initialize batch counter if not exists
|
|
186
|
+
if not hasattr(state_manager.session, "batch_counter"):
|
|
187
|
+
state_manager.session.batch_counter = 0
|
|
211
188
|
|
|
212
|
-
|
|
189
|
+
# Create tool buffer for parallel execution
|
|
190
|
+
tool_buffer = ToolBuffer()
|
|
213
191
|
|
|
214
|
-
|
|
192
|
+
# Track iterations and productivity
|
|
193
|
+
max_iterations = state_manager.session.user_config.get("settings", {}).get("max_iterations", 15)
|
|
194
|
+
unproductive_iterations = 0
|
|
195
|
+
last_productive_iteration = 0
|
|
215
196
|
|
|
216
|
-
|
|
217
|
-
|
|
197
|
+
# Track response state
|
|
198
|
+
response_state = ResponseState()
|
|
218
199
|
|
|
219
|
-
|
|
200
|
+
try:
|
|
201
|
+
# Get message history from session messages
|
|
202
|
+
# Create a copy of the message history to avoid modifying the original
|
|
203
|
+
message_history = list(state_manager.session.messages)
|
|
220
204
|
|
|
205
|
+
async with agent.iter(message, message_history=message_history) as agent_run:
|
|
206
|
+
# Process nodes iteratively
|
|
207
|
+
i = 1
|
|
208
|
+
async for node in agent_run:
|
|
209
|
+
state_manager.session.current_iteration = i
|
|
210
|
+
state_manager.session.iteration_count = i
|
|
221
211
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
):
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
# Use the original callback directly - parallel execution will be handled differently
|
|
234
|
-
buffering_callback = tool_callback
|
|
235
|
-
|
|
236
|
-
if hasattr(node, "request"):
|
|
237
|
-
state_manager.session.messages.append(node.request)
|
|
238
|
-
|
|
239
|
-
if hasattr(node, "thought") and node.thought:
|
|
240
|
-
state_manager.session.messages.append({"thought": node.thought})
|
|
241
|
-
# Display thought immediately if show_thoughts is enabled
|
|
242
|
-
if state_manager.session.show_thoughts:
|
|
243
|
-
await ui.muted(f"THOUGHT: {node.thought}")
|
|
244
|
-
|
|
245
|
-
if hasattr(node, "model_response"):
|
|
246
|
-
state_manager.session.messages.append(node.model_response)
|
|
247
|
-
|
|
248
|
-
if usage_tracker:
|
|
249
|
-
await usage_tracker.track_and_display(node.model_response)
|
|
250
|
-
|
|
251
|
-
# Stream content to callback if provided
|
|
252
|
-
# Use this as fallback when true token streaming is not available
|
|
253
|
-
if streaming_callback and not STREAMING_AVAILABLE:
|
|
254
|
-
for part in node.model_response.parts:
|
|
255
|
-
if hasattr(part, "content") and isinstance(part.content, str):
|
|
256
|
-
content = part.content.strip()
|
|
257
|
-
if content and not content.startswith('{"thought"'):
|
|
258
|
-
# Stream non-JSON content (actual response content)
|
|
259
|
-
await streaming_callback(content)
|
|
260
|
-
|
|
261
|
-
# Enhanced display when thoughts are enabled
|
|
262
|
-
if state_manager.session.show_thoughts:
|
|
263
|
-
# Show raw API response data
|
|
264
|
-
import json
|
|
265
|
-
import re
|
|
266
|
-
|
|
267
|
-
# Display the raw model response parts
|
|
268
|
-
await ui.muted("\n" + "=" * 60)
|
|
269
|
-
await ui.muted(" RAW API RESPONSE DATA:")
|
|
270
|
-
await ui.muted("=" * 60)
|
|
271
|
-
|
|
272
|
-
for idx, part in enumerate(node.model_response.parts):
|
|
273
|
-
part_data = {"part_index": idx, "part_kind": getattr(part, "part_kind", "unknown")}
|
|
274
|
-
|
|
275
|
-
# Add part-specific data
|
|
276
|
-
if hasattr(part, "content"):
|
|
277
|
-
part_data["content"] = (
|
|
278
|
-
part.content[:200] + "..." if len(str(part.content)) > 200 else part.content
|
|
279
|
-
)
|
|
280
|
-
if hasattr(part, "tool_name"):
|
|
281
|
-
part_data["tool_name"] = part.tool_name
|
|
282
|
-
if hasattr(part, "args"):
|
|
283
|
-
part_data["args"] = part.args
|
|
284
|
-
if hasattr(part, "tool_call_id"):
|
|
285
|
-
part_data["tool_call_id"] = part.tool_call_id
|
|
286
|
-
|
|
287
|
-
await ui.muted(json.dumps(part_data, indent=2))
|
|
288
|
-
|
|
289
|
-
await ui.muted("=" * 60)
|
|
290
|
-
|
|
291
|
-
# Count how many tool calls are in this response
|
|
292
|
-
tool_count = sum(
|
|
293
|
-
1
|
|
294
|
-
for part in node.model_response.parts
|
|
295
|
-
if hasattr(part, "part_kind") and part.part_kind == "tool-call"
|
|
296
|
-
)
|
|
297
|
-
if tool_count > 0:
|
|
298
|
-
await ui.muted(f"\n MODEL RESPONSE: Contains {tool_count} tool call(s)")
|
|
299
|
-
|
|
300
|
-
# Display LLM response content
|
|
301
|
-
for part in node.model_response.parts:
|
|
302
|
-
if hasattr(part, "content") and isinstance(part.content, str):
|
|
303
|
-
content = part.content.strip()
|
|
304
|
-
|
|
305
|
-
# Skip empty content
|
|
306
|
-
if not content:
|
|
307
|
-
continue
|
|
308
|
-
|
|
309
|
-
# Estimate tokens in this response
|
|
310
|
-
token_count = estimate_tokens(content)
|
|
311
|
-
|
|
312
|
-
# Display non-JSON content as LLM response
|
|
313
|
-
if not content.startswith('{"thought"'):
|
|
314
|
-
# Truncate very long responses for display
|
|
315
|
-
display_content = content[:500] + "..." if len(content) > 500 else content
|
|
316
|
-
await ui.muted(f"\nRESPONSE: {display_content}")
|
|
317
|
-
await ui.muted(f"TOKENS: ~{token_count}")
|
|
318
|
-
|
|
319
|
-
# Pattern 1: Inline JSON thoughts {"thought": "..."}
|
|
320
|
-
thought_pattern = r'\{"thought":\s*"([^"]+)"\}'
|
|
321
|
-
matches = re.findall(thought_pattern, content)
|
|
322
|
-
for thought in matches:
|
|
323
|
-
await ui.muted(f"REASONING: {thought}")
|
|
324
|
-
|
|
325
|
-
# Pattern 2: Standalone thought JSON objects
|
|
326
|
-
try:
|
|
327
|
-
if content.startswith('{"thought"'):
|
|
328
|
-
thought_obj = json.loads(content)
|
|
329
|
-
if "thought" in thought_obj:
|
|
330
|
-
await ui.muted(f"REASONING: {thought_obj['thought']}")
|
|
331
|
-
except (json.JSONDecodeError, KeyError) as e:
|
|
332
|
-
logger.debug(f"Failed to parse thought JSON: {e}")
|
|
333
|
-
|
|
334
|
-
# Pattern 3: Multi-line thoughts with context
|
|
335
|
-
multiline_pattern = r'\{"thought":\s*"([^"]+(?:\\.[^"]*)*?)"\}'
|
|
336
|
-
multiline_matches = re.findall(multiline_pattern, content, re.DOTALL)
|
|
337
|
-
for thought in multiline_matches:
|
|
338
|
-
if thought not in [m for m in matches]: # Avoid duplicates
|
|
339
|
-
# Clean up escaped characters
|
|
340
|
-
cleaned_thought = thought.replace('\\"', '"').replace("\\n", " ")
|
|
341
|
-
await ui.muted(f"REASONING: {cleaned_thought}")
|
|
342
|
-
|
|
343
|
-
# Check for tool calls and collect them for potential parallel execution
|
|
344
|
-
has_tool_calls = False
|
|
345
|
-
tool_parts = [] # Collect all tool calls from this node
|
|
346
|
-
|
|
347
|
-
for part in node.model_response.parts:
|
|
348
|
-
if part.part_kind == "tool-call" and tool_callback:
|
|
349
|
-
has_tool_calls = True
|
|
350
|
-
tool_parts.append(part)
|
|
351
|
-
|
|
352
|
-
# Display tool call details when thoughts are enabled
|
|
353
|
-
if state_manager.session.show_thoughts:
|
|
354
|
-
# Show each tool as it's collected
|
|
355
|
-
tool_desc = f" COLLECTED: {part.tool_name}"
|
|
356
|
-
if hasattr(part, "args") and isinstance(part.args, dict):
|
|
357
|
-
if part.tool_name == "read_file" and "file_path" in part.args:
|
|
358
|
-
tool_desc += f" → {part.args['file_path']}"
|
|
359
|
-
elif part.tool_name == "grep" and "pattern" in part.args:
|
|
360
|
-
tool_desc += f" → pattern: '{part.args['pattern']}'"
|
|
361
|
-
elif part.tool_name == "list_dir" and "directory" in part.args:
|
|
362
|
-
tool_desc += f" → {part.args['directory']}"
|
|
363
|
-
elif part.tool_name == "run_command" and "command" in part.args:
|
|
364
|
-
tool_desc += f" → {part.args['command']}"
|
|
365
|
-
await ui.muted(tool_desc)
|
|
212
|
+
# Handle token-level streaming for model request nodes
|
|
213
|
+
Agent, _ = get_agent_tool()
|
|
214
|
+
if streaming_callback and STREAMING_AVAILABLE and Agent.is_model_request_node(node):
|
|
215
|
+
async with node.stream(agent_run.ctx) as request_stream:
|
|
216
|
+
async for event in request_stream:
|
|
217
|
+
if isinstance(event, PartDeltaEvent) and isinstance(
|
|
218
|
+
event.delta, TextPartDelta
|
|
219
|
+
):
|
|
220
|
+
# Stream individual token deltas
|
|
221
|
+
if event.delta.content_delta and streaming_callback:
|
|
222
|
+
await streaming_callback(event.delta.content_delta)
|
|
366
223
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
224
|
+
empty_response, empty_reason = await _process_node(
|
|
225
|
+
node,
|
|
226
|
+
tool_callback,
|
|
227
|
+
state_manager,
|
|
228
|
+
tool_buffer,
|
|
229
|
+
streaming_callback,
|
|
230
|
+
usage_tracker,
|
|
231
|
+
response_state,
|
|
374
232
|
)
|
|
375
233
|
|
|
376
|
-
#
|
|
377
|
-
if
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
234
|
+
# Handle empty response by asking user for help instead of giving up
|
|
235
|
+
if empty_response:
|
|
236
|
+
# Track consecutive empty responses
|
|
237
|
+
if not hasattr(state_manager.session, "consecutive_empty_responses"):
|
|
238
|
+
state_manager.session.consecutive_empty_responses = 0
|
|
239
|
+
state_manager.session.consecutive_empty_responses += 1
|
|
240
|
+
|
|
241
|
+
# IMMEDIATE AGGRESSIVE INTERVENTION on ANY empty response
|
|
242
|
+
if state_manager.session.consecutive_empty_responses >= 1:
|
|
243
|
+
# Get context about what was happening
|
|
244
|
+
last_tools_used = []
|
|
245
|
+
if state_manager.session.tool_calls:
|
|
246
|
+
for tc in state_manager.session.tool_calls[-3:]:
|
|
247
|
+
tool_name = tc.get("tool", "unknown")
|
|
248
|
+
tool_args = tc.get("args", {})
|
|
249
|
+
tool_desc = tool_name
|
|
250
|
+
if tool_name in ["grep", "glob"] and isinstance(tool_args, dict):
|
|
251
|
+
pattern = tool_args.get("pattern", "")
|
|
252
|
+
tool_desc = f"{tool_name}('{pattern}')"
|
|
253
|
+
elif tool_name == "read_file" and isinstance(tool_args, dict):
|
|
254
|
+
path = tool_args.get("file_path", tool_args.get("filepath", ""))
|
|
255
|
+
tool_desc = f"{tool_name}('{path}')"
|
|
256
|
+
last_tools_used.append(tool_desc)
|
|
257
|
+
|
|
258
|
+
tools_context = (
|
|
259
|
+
f"Recent tools: {', '.join(last_tools_used)}"
|
|
260
|
+
if last_tools_used
|
|
261
|
+
else "No tools used yet"
|
|
388
262
|
)
|
|
389
263
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
if state_manager.session.show_thoughts:
|
|
393
|
-
await ui.muted(
|
|
394
|
-
f"\n NODE SUMMARY: {len(tool_parts)} tool(s) collected in this response"
|
|
395
|
-
)
|
|
396
|
-
|
|
397
|
-
# Check if ALL tools in this node are read-only
|
|
398
|
-
all_read_only = all(part.tool_name in READ_ONLY_TOOLS for part in tool_parts)
|
|
264
|
+
# AGGRESSIVE prompt - YOU FAILED, TRY HARDER
|
|
265
|
+
force_action_content = f"""FAILURE DETECTED: You returned {"an " + empty_reason if empty_reason != "empty" else "an empty"} response.
|
|
399
266
|
|
|
400
|
-
|
|
401
|
-
# batch_read_only_tools() function to group consecutive read-only tools and execute
|
|
402
|
-
# them in parallel even when mixed with write/execute tools. For example:
|
|
403
|
-
# [read, read, write, read] should execute as: [read||read], [write], [read]
|
|
404
|
-
# instead of all sequential. The batch_read_only_tools() function exists but is unused.
|
|
405
|
-
if all_read_only and len(tool_parts) > 1 and buffering_callback:
|
|
406
|
-
# Execute read-only tools in parallel!
|
|
407
|
-
import time
|
|
267
|
+
This is UNACCEPTABLE. You FAILED to produce output.
|
|
408
268
|
|
|
409
|
-
|
|
269
|
+
Task: {message[:200]}...
|
|
270
|
+
{tools_context}
|
|
271
|
+
Current iteration: {i}
|
|
410
272
|
|
|
411
|
-
|
|
412
|
-
await ui.muted("\n" + "=" * 60)
|
|
413
|
-
await ui.muted(
|
|
414
|
-
f" PARALLEL BATCH: Executing {len(tool_parts)} read-only tools concurrently"
|
|
415
|
-
)
|
|
416
|
-
await ui.muted("=" * 60)
|
|
417
|
-
|
|
418
|
-
for idx, part in enumerate(tool_parts, 1):
|
|
419
|
-
tool_desc = f" [{idx}] {part.tool_name}"
|
|
420
|
-
if hasattr(part, "args") and isinstance(part.args, dict):
|
|
421
|
-
if part.tool_name == "read_file" and "file_path" in part.args:
|
|
422
|
-
tool_desc += f" → {part.args['file_path']}"
|
|
423
|
-
elif part.tool_name == "grep" and "pattern" in part.args:
|
|
424
|
-
tool_desc += f" → pattern: '{part.args['pattern']}'"
|
|
425
|
-
elif part.tool_name == "list_dir" and "directory" in part.args:
|
|
426
|
-
tool_desc += f" → {part.args['directory']}"
|
|
427
|
-
elif part.tool_name == "glob" and "pattern" in part.args:
|
|
428
|
-
tool_desc += f" → pattern: '{part.args['pattern']}'"
|
|
429
|
-
await ui.muted(tool_desc)
|
|
430
|
-
await ui.muted("=" * 60)
|
|
431
|
-
|
|
432
|
-
# Execute in parallel
|
|
433
|
-
tool_tuples = [(part, node) for part in tool_parts]
|
|
434
|
-
await execute_tools_parallel(tool_tuples, buffering_callback)
|
|
273
|
+
TRY AGAIN RIGHT NOW:
|
|
435
274
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
await ui.muted(
|
|
441
|
-
f" Parallel batch completed in {elapsed_time:.0f}ms ({speedup:.1f}x faster than sequential)"
|
|
442
|
-
)
|
|
275
|
+
1. If your search returned no results → Try a DIFFERENT search pattern
|
|
276
|
+
2. If you found what you need → Use TUNACODE_TASK_COMPLETE
|
|
277
|
+
3. If you're stuck → EXPLAIN SPECIFICALLY what's blocking you
|
|
278
|
+
4. If you need to explore → Use list_dir or broader searches
|
|
443
279
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
if (
|
|
448
|
-
state_manager.session.show_thoughts
|
|
449
|
-
and part.tool_name not in READ_ONLY_TOOLS
|
|
450
|
-
):
|
|
451
|
-
await ui.muted(f"\n SEQUENTIAL: {part.tool_name} (write/execute tool)")
|
|
452
|
-
|
|
453
|
-
# Execute the tool
|
|
454
|
-
if buffering_callback:
|
|
455
|
-
await buffering_callback(part, node)
|
|
456
|
-
|
|
457
|
-
# Handle tool returns
|
|
458
|
-
for part in node.model_response.parts:
|
|
459
|
-
if part.part_kind == "tool-return":
|
|
460
|
-
state_manager.session.messages.append(
|
|
461
|
-
f"OBSERVATION[{part.tool_name}]: {part.content}"
|
|
462
|
-
)
|
|
280
|
+
YOU MUST PRODUCE REAL OUTPUT IN THIS RESPONSE. NO EXCUSES.
|
|
281
|
+
EXECUTE A TOOL OR PROVIDE SUBSTANTIAL CONTENT.
|
|
282
|
+
DO NOT RETURN ANOTHER EMPTY RESPONSE."""
|
|
463
283
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
await ui.muted(f"TOOL RESULT: {display_content}")
|
|
471
|
-
|
|
472
|
-
# If no structured tool calls found, try parsing JSON from text content
|
|
473
|
-
if not has_tool_calls and buffering_callback:
|
|
474
|
-
for part in node.model_response.parts:
|
|
475
|
-
if hasattr(part, "content") and isinstance(part.content, str):
|
|
476
|
-
try:
|
|
477
|
-
await extract_and_execute_tool_calls(
|
|
478
|
-
part.content, buffering_callback, state_manager
|
|
284
|
+
model_request_cls = get_model_messages()[0]
|
|
285
|
+
# Get UserPromptPart from the cached helper
|
|
286
|
+
UserPromptPart = _get_user_prompt_part_class()
|
|
287
|
+
user_prompt_part = UserPromptPart(
|
|
288
|
+
content=force_action_content,
|
|
289
|
+
part_kind="user-prompt",
|
|
479
290
|
)
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
if state_manager.session.show_thoughts:
|
|
484
|
-
await ui.error(str(e))
|
|
485
|
-
# Continue processing other parts instead of failing completely
|
|
486
|
-
continue
|
|
487
|
-
|
|
488
|
-
# Final flush: disabled temporarily while fixing the parallel execution design
|
|
489
|
-
# The buffer is not being used in the current implementation
|
|
490
|
-
# if tool_callback and buffer.has_tasks():
|
|
491
|
-
# buffered_tasks = buffer.flush()
|
|
492
|
-
# if state_manager.session.show_thoughts:
|
|
493
|
-
# await ui.muted(
|
|
494
|
-
# f"Final flush: Executing {len(buffered_tasks)} remaining read-only tools in parallel"
|
|
495
|
-
# )
|
|
496
|
-
# await execute_tools_parallel(buffered_tasks, tool_callback)
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
def get_or_create_agent(model: ModelName, state_manager: StateManager) -> PydanticAgent:
|
|
500
|
-
if model not in state_manager.session.agents:
|
|
501
|
-
max_retries = state_manager.session.user_config.get("settings", {}).get("max_retries", 3)
|
|
502
|
-
|
|
503
|
-
# Lazy import Agent and Tool
|
|
504
|
-
Agent, Tool = get_agent_tool()
|
|
505
|
-
|
|
506
|
-
# Load system prompt
|
|
507
|
-
prompt_path = Path(__file__).parent.parent.parent / "prompts" / "system.md"
|
|
508
|
-
try:
|
|
509
|
-
with open(prompt_path, "r", encoding="utf-8") as f:
|
|
510
|
-
system_prompt = f.read().strip()
|
|
511
|
-
except FileNotFoundError:
|
|
512
|
-
# Fallback to system.txt if system.md not found
|
|
513
|
-
prompt_path = Path(__file__).parent.parent.parent / "prompts" / "system.txt"
|
|
514
|
-
try:
|
|
515
|
-
with open(prompt_path, "r", encoding="utf-8") as f:
|
|
516
|
-
system_prompt = f.read().strip()
|
|
517
|
-
except FileNotFoundError:
|
|
518
|
-
# Use a default system prompt if neither file exists
|
|
519
|
-
system_prompt = "You are a helpful AI assistant for software development tasks."
|
|
520
|
-
|
|
521
|
-
# Load TUNACODE.md context
|
|
522
|
-
# Use sync version of get_code_style to avoid nested event loop issues
|
|
523
|
-
try:
|
|
524
|
-
from pathlib import Path as PathlibPath
|
|
525
|
-
|
|
526
|
-
tunacode_path = PathlibPath.cwd() / "TUNACODE.md"
|
|
527
|
-
if tunacode_path.exists():
|
|
528
|
-
tunacode_content = tunacode_path.read_text(encoding="utf-8")
|
|
529
|
-
if tunacode_content.strip():
|
|
530
|
-
# Log that we found TUNACODE.md
|
|
531
|
-
logger.info("📄 TUNACODE.md located: Loading context...")
|
|
532
|
-
|
|
533
|
-
system_prompt += "\n\n# Project Context from TUNACODE.md\n" + tunacode_content
|
|
534
|
-
else:
|
|
535
|
-
# Log that TUNACODE.md was not found
|
|
536
|
-
logger.info("📄 TUNACODE.md not found: Using default context")
|
|
537
|
-
except Exception as e:
|
|
538
|
-
# Log errors loading TUNACODE.md at debug level
|
|
539
|
-
logger.debug(f"Error loading TUNACODE.md: {e}")
|
|
540
|
-
|
|
541
|
-
todo_tool = TodoTool(state_manager=state_manager)
|
|
542
|
-
|
|
543
|
-
try:
|
|
544
|
-
# Only add todo section if there are actual todos
|
|
545
|
-
current_todos = todo_tool.get_current_todos_sync()
|
|
546
|
-
if current_todos != "No todos found":
|
|
547
|
-
system_prompt += f'\n\n# Current Todo List\n\nYou have existing todos that need attention:\n\n{current_todos}\n\nRemember to check progress on these todos and update them as you work. Use todo("list") to see current status anytime.'
|
|
548
|
-
except Exception as e:
|
|
549
|
-
# Log error but don't fail agent creation
|
|
550
|
-
|
|
551
|
-
logger.warning(f"Warning: Failed to load todos: {e}")
|
|
552
|
-
|
|
553
|
-
state_manager.session.agents[model] = Agent(
|
|
554
|
-
model=model,
|
|
555
|
-
system_prompt=system_prompt,
|
|
556
|
-
tools=[
|
|
557
|
-
Tool(bash, max_retries=max_retries),
|
|
558
|
-
Tool(glob, max_retries=max_retries),
|
|
559
|
-
Tool(grep, max_retries=max_retries),
|
|
560
|
-
Tool(list_dir, max_retries=max_retries),
|
|
561
|
-
Tool(read_file, max_retries=max_retries),
|
|
562
|
-
Tool(run_command, max_retries=max_retries),
|
|
563
|
-
Tool(todo_tool._execute, max_retries=max_retries),
|
|
564
|
-
Tool(update_file, max_retries=max_retries),
|
|
565
|
-
Tool(write_file, max_retries=max_retries),
|
|
566
|
-
],
|
|
567
|
-
mcp_servers=get_mcp_servers(state_manager),
|
|
568
|
-
)
|
|
569
|
-
return state_manager.session.agents[model]
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
def patch_tool_messages(
|
|
573
|
-
error_message: ErrorMessage = "Tool operation failed",
|
|
574
|
-
state_manager: StateManager = None,
|
|
575
|
-
):
|
|
576
|
-
"""
|
|
577
|
-
Find any tool calls without responses and add synthetic error responses for them.
|
|
578
|
-
Takes an error message to use in the synthesized tool response.
|
|
579
|
-
|
|
580
|
-
Ignores tools that have corresponding retry prompts as the model is already
|
|
581
|
-
addressing them.
|
|
582
|
-
"""
|
|
583
|
-
if state_manager is None:
|
|
584
|
-
raise ValueError("state_manager is required for patch_tool_messages")
|
|
585
|
-
|
|
586
|
-
messages = state_manager.session.messages
|
|
587
|
-
|
|
588
|
-
if not messages:
|
|
589
|
-
return
|
|
590
|
-
|
|
591
|
-
# Map tool calls to their tool returns
|
|
592
|
-
tool_calls: dict[ToolCallId, ToolName] = {} # tool_call_id -> tool_name
|
|
593
|
-
tool_returns: set[ToolCallId] = set() # set of tool_call_ids with returns
|
|
594
|
-
retry_prompts: set[ToolCallId] = set() # set of tool_call_ids with retry prompts
|
|
595
|
-
|
|
596
|
-
for message in messages:
|
|
597
|
-
if hasattr(message, "parts"):
|
|
598
|
-
for part in message.parts:
|
|
599
|
-
if (
|
|
600
|
-
hasattr(part, "part_kind")
|
|
601
|
-
and hasattr(part, "tool_call_id")
|
|
602
|
-
and part.tool_call_id
|
|
603
|
-
):
|
|
604
|
-
if part.part_kind == "tool-call":
|
|
605
|
-
tool_calls[part.tool_call_id] = part.tool_name
|
|
606
|
-
elif part.part_kind == "tool-return":
|
|
607
|
-
tool_returns.add(part.tool_call_id)
|
|
608
|
-
elif part.part_kind == "retry-prompt":
|
|
609
|
-
retry_prompts.add(part.tool_call_id)
|
|
610
|
-
|
|
611
|
-
# Identify orphaned tools (those without responses and not being retried)
|
|
612
|
-
for tool_call_id, tool_name in list(tool_calls.items()):
|
|
613
|
-
if tool_call_id not in tool_returns and tool_call_id not in retry_prompts:
|
|
614
|
-
# Import ModelRequest and ToolReturnPart lazily
|
|
615
|
-
ModelRequest, ToolReturnPart = get_model_messages()
|
|
616
|
-
messages.append(
|
|
617
|
-
ModelRequest(
|
|
618
|
-
parts=[
|
|
619
|
-
ToolReturnPart(
|
|
620
|
-
tool_name=tool_name,
|
|
621
|
-
content=error_message,
|
|
622
|
-
tool_call_id=tool_call_id,
|
|
623
|
-
timestamp=datetime.now(timezone.utc),
|
|
624
|
-
part_kind="tool-return",
|
|
291
|
+
force_message = model_request_cls(
|
|
292
|
+
parts=[user_prompt_part],
|
|
293
|
+
kind="request",
|
|
625
294
|
)
|
|
626
|
-
|
|
627
|
-
kind="request",
|
|
628
|
-
)
|
|
629
|
-
)
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
async def parse_json_tool_calls(
|
|
633
|
-
text: str, tool_callback: Optional[ToolCallback], state_manager: StateManager
|
|
634
|
-
):
|
|
635
|
-
"""
|
|
636
|
-
Parse JSON tool calls from text when structured tool calling fails.
|
|
637
|
-
Fallback for when API providers don't support proper tool calling.
|
|
638
|
-
"""
|
|
639
|
-
if not tool_callback:
|
|
640
|
-
return
|
|
641
|
-
|
|
642
|
-
# Pattern for JSON tool calls: {"tool": "tool_name", "args": {...}}
|
|
643
|
-
# Find potential JSON objects and parse them
|
|
644
|
-
potential_jsons = []
|
|
645
|
-
brace_count = 0
|
|
646
|
-
start_pos = -1
|
|
647
|
-
|
|
648
|
-
for i, char in enumerate(text):
|
|
649
|
-
if char == "{":
|
|
650
|
-
if brace_count == 0:
|
|
651
|
-
start_pos = i
|
|
652
|
-
brace_count += 1
|
|
653
|
-
elif char == "}":
|
|
654
|
-
brace_count -= 1
|
|
655
|
-
if brace_count == 0 and start_pos != -1:
|
|
656
|
-
potential_json = text[start_pos : i + 1]
|
|
657
|
-
try:
|
|
658
|
-
parsed = json.loads(potential_json)
|
|
659
|
-
if isinstance(parsed, dict) and "tool" in parsed and "args" in parsed:
|
|
660
|
-
potential_jsons.append((parsed["tool"], parsed["args"]))
|
|
661
|
-
except json.JSONDecodeError:
|
|
662
|
-
logger.debug(
|
|
663
|
-
f"Failed to parse potential JSON tool call: {potential_json[:50]}..."
|
|
664
|
-
)
|
|
665
|
-
start_pos = -1
|
|
666
|
-
|
|
667
|
-
matches = potential_jsons
|
|
668
|
-
|
|
669
|
-
for tool_name, args in matches:
|
|
670
|
-
try:
|
|
671
|
-
# Create a mock tool call object
|
|
672
|
-
class MockToolCall:
|
|
673
|
-
def __init__(self, tool_name: str, args: dict):
|
|
674
|
-
self.tool_name = tool_name
|
|
675
|
-
self.args = args
|
|
676
|
-
self.tool_call_id = f"fallback_{datetime.now().timestamp()}"
|
|
677
|
-
|
|
678
|
-
class MockNode:
|
|
679
|
-
pass
|
|
680
|
-
|
|
681
|
-
# Execute the tool through the callback
|
|
682
|
-
mock_call = MockToolCall(tool_name, args)
|
|
683
|
-
mock_node = MockNode()
|
|
684
|
-
|
|
685
|
-
await tool_callback(mock_call, mock_node)
|
|
686
|
-
|
|
687
|
-
if state_manager.session.show_thoughts:
|
|
688
|
-
from tunacode.ui import console as ui
|
|
689
|
-
|
|
690
|
-
await ui.muted(f"FALLBACK: Executed {tool_name} via JSON parsing")
|
|
295
|
+
state_manager.session.messages.append(force_message)
|
|
691
296
|
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
from tunacode.ui import console as ui
|
|
297
|
+
if state_manager.session.show_thoughts:
|
|
298
|
+
from tunacode.ui import console as ui
|
|
695
299
|
|
|
696
|
-
|
|
300
|
+
await ui.warning(
|
|
301
|
+
"\n⚠️ EMPTY RESPONSE FAILURE - AGGRESSIVE RETRY TRIGGERED"
|
|
302
|
+
)
|
|
303
|
+
await ui.muted(f" Reason: {empty_reason}")
|
|
304
|
+
await ui.muted(f" Recent tools: {tools_context}")
|
|
305
|
+
await ui.muted(" Injecting 'YOU FAILED TRY HARDER' prompt")
|
|
697
306
|
|
|
307
|
+
# Reset counter after aggressive intervention
|
|
308
|
+
state_manager.session.consecutive_empty_responses = 0
|
|
309
|
+
else:
|
|
310
|
+
# Reset counter on successful response
|
|
311
|
+
if hasattr(state_manager.session, "consecutive_empty_responses"):
|
|
312
|
+
state_manager.session.consecutive_empty_responses = 0
|
|
698
313
|
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
"""
|
|
703
|
-
Extract tool calls from text content and execute them.
|
|
704
|
-
Supports multiple formats for maximum compatibility.
|
|
705
|
-
"""
|
|
706
|
-
if not tool_callback:
|
|
707
|
-
return
|
|
708
|
-
|
|
709
|
-
# Format 1: {"tool": "name", "args": {...}}
|
|
710
|
-
await parse_json_tool_calls(text, tool_callback, state_manager)
|
|
314
|
+
if hasattr(node, "result") and node.result and hasattr(node.result, "output"):
|
|
315
|
+
if node.result.output:
|
|
316
|
+
response_state.has_user_response = True
|
|
711
317
|
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
318
|
+
# Track productivity - check if any tools were used in this iteration
|
|
319
|
+
iteration_had_tools = False
|
|
320
|
+
if hasattr(node, "model_response"):
|
|
321
|
+
for part in node.model_response.parts:
|
|
322
|
+
if hasattr(part, "part_kind") and part.part_kind == "tool-call":
|
|
323
|
+
iteration_had_tools = True
|
|
324
|
+
break
|
|
325
|
+
|
|
326
|
+
if iteration_had_tools:
|
|
327
|
+
# Reset unproductive counter
|
|
328
|
+
unproductive_iterations = 0
|
|
329
|
+
last_productive_iteration = i
|
|
330
|
+
else:
|
|
331
|
+
# Increment unproductive counter
|
|
332
|
+
unproductive_iterations += 1
|
|
333
|
+
|
|
334
|
+
# After 3 unproductive iterations, force action
|
|
335
|
+
if unproductive_iterations >= 3 and not response_state.task_completed:
|
|
336
|
+
no_progress_content = f"""ALERT: No tools executed for {unproductive_iterations} iterations.
|
|
337
|
+
|
|
338
|
+
Last productive iteration: {last_productive_iteration}
|
|
339
|
+
Current iteration: {i}/{max_iterations}
|
|
340
|
+
Task: {message[:200]}...
|
|
341
|
+
|
|
342
|
+
You're describing actions but not executing them. You MUST:
|
|
343
|
+
|
|
344
|
+
1. If task is COMPLETE: Start response with TUNACODE_TASK_COMPLETE
|
|
345
|
+
2. If task needs work: Execute a tool RIGHT NOW (grep, read_file, bash, etc.)
|
|
346
|
+
3. If stuck: Explain the specific blocker
|
|
347
|
+
|
|
348
|
+
NO MORE DESCRIPTIONS. Take ACTION or mark COMPLETE."""
|
|
349
|
+
|
|
350
|
+
model_request_cls = get_model_messages()[0]
|
|
351
|
+
# Get UserPromptPart from the cached helper
|
|
352
|
+
UserPromptPart = _get_user_prompt_part_class()
|
|
353
|
+
user_prompt_part = UserPromptPart(
|
|
354
|
+
content=no_progress_content,
|
|
355
|
+
part_kind="user-prompt",
|
|
356
|
+
)
|
|
357
|
+
progress_message = model_request_cls(
|
|
358
|
+
parts=[user_prompt_part],
|
|
359
|
+
kind="request",
|
|
360
|
+
)
|
|
361
|
+
state_manager.session.messages.append(progress_message)
|
|
715
362
|
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
tool_data = json.loads(match)
|
|
719
|
-
if "tool" in tool_data and "args" in tool_data:
|
|
363
|
+
if state_manager.session.show_thoughts:
|
|
364
|
+
from tunacode.ui import console as ui
|
|
720
365
|
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
self.args = args
|
|
725
|
-
self.tool_call_id = f"codeblock_{datetime.now().timestamp()}"
|
|
366
|
+
await ui.warning(
|
|
367
|
+
f"⚠️ NO PROGRESS: {unproductive_iterations} iterations without tool usage"
|
|
368
|
+
)
|
|
726
369
|
|
|
727
|
-
|
|
728
|
-
|
|
370
|
+
# Reset counter after intervention
|
|
371
|
+
unproductive_iterations = 0
|
|
729
372
|
|
|
730
|
-
|
|
731
|
-
|
|
373
|
+
# REMOVED: Recursive satisfaction check that caused empty responses
|
|
374
|
+
# The agent now decides completion using TUNACODE_TASK_COMPLETE marker
|
|
375
|
+
# This eliminates recursive agent calls and gives control back to the agent
|
|
732
376
|
|
|
733
|
-
|
|
377
|
+
# Store original query for reference
|
|
378
|
+
if not hasattr(state_manager.session, "original_query"):
|
|
379
|
+
state_manager.session.original_query = message
|
|
734
380
|
|
|
381
|
+
# Display iteration progress if thoughts are enabled
|
|
735
382
|
if state_manager.session.show_thoughts:
|
|
736
383
|
from tunacode.ui import console as ui
|
|
737
384
|
|
|
738
|
-
await ui.muted(f"
|
|
739
|
-
|
|
740
|
-
except (json.JSONDecodeError, KeyError, Exception) as e:
|
|
741
|
-
if state_manager.session.show_thoughts:
|
|
742
|
-
from tunacode.ui import console as ui
|
|
743
|
-
|
|
744
|
-
await ui.error(f"Error parsing code block tool call: {str(e)}")
|
|
385
|
+
await ui.muted(f"\nITERATION: {i}/{max_iterations} (Request ID: {request_id})")
|
|
745
386
|
|
|
387
|
+
# Show summary of tools used so far
|
|
388
|
+
if state_manager.session.tool_calls:
|
|
389
|
+
tool_summary: dict[str, int] = {}
|
|
390
|
+
for tc in state_manager.session.tool_calls:
|
|
391
|
+
tool_name = tc.get("tool", "unknown")
|
|
392
|
+
tool_summary[tool_name] = tool_summary.get(tool_name, 0) + 1
|
|
746
393
|
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
tool_callback: Optional[ToolCallback] = None,
|
|
752
|
-
streaming_callback: Optional[callable] = None,
|
|
753
|
-
) -> AgentRun:
|
|
754
|
-
try:
|
|
755
|
-
agent = get_or_create_agent(model, state_manager)
|
|
756
|
-
mh = state_manager.session.messages.copy()
|
|
757
|
-
# Get max iterations from config (default: 40)
|
|
758
|
-
max_iterations = state_manager.session.user_config.get("settings", {}).get(
|
|
759
|
-
"max_iterations", 40
|
|
760
|
-
)
|
|
761
|
-
fallback_enabled = state_manager.session.user_config.get("settings", {}).get(
|
|
762
|
-
"fallback_response", True
|
|
763
|
-
)
|
|
394
|
+
summary_str = ", ".join(
|
|
395
|
+
[f"{name}: {count}" for name, count in tool_summary.items()]
|
|
396
|
+
)
|
|
397
|
+
await ui.muted(f"TOOLS USED: {summary_str}")
|
|
764
398
|
|
|
765
|
-
|
|
766
|
-
|
|
399
|
+
# User clarification: Ask user for guidance when explicitly awaiting
|
|
400
|
+
if response_state.awaiting_user_guidance:
|
|
401
|
+
# Build a progress summary
|
|
402
|
+
tool_summary = {}
|
|
403
|
+
if state_manager.session.tool_calls:
|
|
404
|
+
for tc in state_manager.session.tool_calls:
|
|
405
|
+
tool_name = tc.get("tool", "unknown")
|
|
406
|
+
tool_summary[tool_name] = tool_summary.get(tool_name, 0) + 1
|
|
767
407
|
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
408
|
+
tools_used_str = (
|
|
409
|
+
", ".join([f"{name}: {count}" for name, count in tool_summary.items()])
|
|
410
|
+
if tool_summary
|
|
411
|
+
else "No tools used yet"
|
|
412
|
+
)
|
|
773
413
|
|
|
774
|
-
|
|
775
|
-
|
|
414
|
+
# Create user message asking for clarification
|
|
415
|
+
model_request_cls = get_model_messages()[0]
|
|
416
|
+
# Get UserPromptPart from the cached helper
|
|
417
|
+
UserPromptPart = _get_user_prompt_part_class()
|
|
776
418
|
|
|
777
|
-
|
|
778
|
-
tool_buffer = ToolBuffer()
|
|
419
|
+
clarification_content = f"""I need clarification to continue.
|
|
779
420
|
|
|
780
|
-
|
|
781
|
-
if state_manager.session.show_thoughts and hasattr(state_manager, "tunacode_preview"):
|
|
782
|
-
from tunacode.ui import console as ui
|
|
421
|
+
Original request: {getattr(state_manager.session, "original_query", "your request")}
|
|
783
422
|
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
423
|
+
Progress so far:
|
|
424
|
+
- Iterations: {i}
|
|
425
|
+
- Tools used: {tools_used_str}
|
|
787
426
|
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
from tunacode.ui import console as ui
|
|
427
|
+
If the task is complete, I should respond with TUNACODE_TASK_COMPLETE.
|
|
428
|
+
Otherwise, please provide specific guidance on what to do next."""
|
|
791
429
|
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
430
|
+
user_prompt_part = UserPromptPart(
|
|
431
|
+
content=clarification_content,
|
|
432
|
+
part_kind="user-prompt",
|
|
433
|
+
)
|
|
434
|
+
clarification_message = model_request_cls(
|
|
435
|
+
parts=[user_prompt_part],
|
|
436
|
+
kind="request",
|
|
437
|
+
)
|
|
438
|
+
state_manager.session.messages.append(clarification_message)
|
|
798
439
|
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
async for node in agent_run:
|
|
802
|
-
state_manager.session.current_iteration = i + 1
|
|
440
|
+
if state_manager.session.show_thoughts:
|
|
441
|
+
from tunacode.ui import console as ui
|
|
803
442
|
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
async for event in request_stream:
|
|
808
|
-
if isinstance(event, PartDeltaEvent) and isinstance(
|
|
809
|
-
event.delta, TextPartDelta
|
|
810
|
-
):
|
|
811
|
-
# Stream individual token deltas
|
|
812
|
-
if event.delta.content_delta:
|
|
813
|
-
await streaming_callback(event.delta.content_delta)
|
|
443
|
+
await ui.muted(
|
|
444
|
+
"\n🤔 SEEKING CLARIFICATION: Asking user for guidance on task progress"
|
|
445
|
+
)
|
|
814
446
|
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
tool_callback,
|
|
818
|
-
state_manager,
|
|
819
|
-
tool_buffer,
|
|
820
|
-
streaming_callback,
|
|
821
|
-
usage_tracker,
|
|
822
|
-
)
|
|
823
|
-
if hasattr(node, "result") and node.result and hasattr(node.result, "output"):
|
|
824
|
-
if node.result.output:
|
|
825
|
-
response_state.has_user_response = True
|
|
826
|
-
i += 1
|
|
827
|
-
state_manager.session.iteration_count = i
|
|
447
|
+
# Mark that we've asked for user guidance
|
|
448
|
+
response_state.awaiting_user_guidance = True
|
|
828
449
|
|
|
829
|
-
#
|
|
830
|
-
if
|
|
831
|
-
|
|
450
|
+
# Check if task is explicitly completed
|
|
451
|
+
if response_state.task_completed:
|
|
452
|
+
if state_manager.session.show_thoughts:
|
|
453
|
+
from tunacode.ui import console as ui
|
|
832
454
|
|
|
833
|
-
|
|
455
|
+
await ui.success("Task completed successfully")
|
|
456
|
+
break
|
|
834
457
|
|
|
835
|
-
|
|
458
|
+
if i >= max_iterations and not response_state.task_completed:
|
|
459
|
+
# Instead of breaking, ask user if they want to continue
|
|
460
|
+
# Build progress summary
|
|
461
|
+
tool_summary = {}
|
|
836
462
|
if state_manager.session.tool_calls:
|
|
837
|
-
tool_summary = {}
|
|
838
463
|
for tc in state_manager.session.tool_calls:
|
|
839
464
|
tool_name = tc.get("tool", "unknown")
|
|
840
465
|
tool_summary[tool_name] = tool_summary.get(tool_name, 0) + 1
|
|
841
466
|
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
467
|
+
tools_str = (
|
|
468
|
+
", ".join([f"{name}: {count}" for name, count in tool_summary.items()])
|
|
469
|
+
if tool_summary
|
|
470
|
+
else "No tools used"
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
extend_content = f"""I've reached the iteration limit ({max_iterations}).
|
|
474
|
+
|
|
475
|
+
Progress summary:
|
|
476
|
+
- Tools used: {tools_str}
|
|
477
|
+
- Iterations completed: {i}
|
|
478
|
+
|
|
479
|
+
The task appears incomplete. Would you like me to:
|
|
480
|
+
1. Continue working (I can extend the limit)
|
|
481
|
+
2. Summarize what I've done and stop
|
|
482
|
+
3. Try a different approach
|
|
483
|
+
|
|
484
|
+
Please let me know how to proceed."""
|
|
485
|
+
|
|
486
|
+
# Create user message
|
|
487
|
+
model_request_cls = get_model_messages()[0]
|
|
488
|
+
# Get UserPromptPart from the cached helper
|
|
489
|
+
UserPromptPart = _get_user_prompt_part_class()
|
|
490
|
+
user_prompt_part = UserPromptPart(
|
|
491
|
+
content=extend_content,
|
|
492
|
+
part_kind="user-prompt",
|
|
493
|
+
)
|
|
494
|
+
extend_message = model_request_cls(
|
|
495
|
+
parts=[user_prompt_part],
|
|
496
|
+
kind="request",
|
|
497
|
+
)
|
|
498
|
+
state_manager.session.messages.append(extend_message)
|
|
846
499
|
|
|
847
|
-
if i >= max_iterations:
|
|
848
500
|
if state_manager.session.show_thoughts:
|
|
849
501
|
from tunacode.ui import console as ui
|
|
850
502
|
|
|
851
|
-
await ui.
|
|
852
|
-
|
|
503
|
+
await ui.muted(
|
|
504
|
+
f"\n📊 ITERATION LIMIT: Asking user for guidance at {max_iterations} iterations"
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
# Extend the limit temporarily to allow processing the response
|
|
508
|
+
max_iterations += 5 # Give 5 more iterations to process user guidance
|
|
509
|
+
response_state.awaiting_user_guidance = True
|
|
510
|
+
|
|
511
|
+
# Increment iteration counter
|
|
512
|
+
i += 1
|
|
853
513
|
|
|
854
514
|
# Final flush: execute any remaining buffered read-only tools
|
|
855
515
|
if tool_callback and tool_buffer.has_tasks():
|
|
@@ -894,7 +554,13 @@ async def process_request(
|
|
|
894
554
|
)
|
|
895
555
|
|
|
896
556
|
# If we need to add a fallback response, create a wrapper
|
|
897
|
-
|
|
557
|
+
# Don't add fallback if task was explicitly completed
|
|
558
|
+
if (
|
|
559
|
+
not response_state.has_user_response
|
|
560
|
+
and not response_state.task_completed
|
|
561
|
+
and i >= max_iterations
|
|
562
|
+
and fallback_enabled
|
|
563
|
+
):
|
|
898
564
|
patch_tool_messages("Task incomplete", state_manager=state_manager)
|
|
899
565
|
response_state.has_final_synthesis = True
|
|
900
566
|
|
|
@@ -935,7 +601,7 @@ async def process_request(
|
|
|
935
601
|
if verbosity in ["normal", "detailed"]:
|
|
936
602
|
# Add what was attempted
|
|
937
603
|
if tool_calls_summary:
|
|
938
|
-
tool_counts = {}
|
|
604
|
+
tool_counts: dict[str, int] = {}
|
|
939
605
|
for tool in tool_calls_summary:
|
|
940
606
|
tool_counts[tool] = tool_counts.get(tool, 0) + 1
|
|
941
607
|
|
|
@@ -992,52 +658,29 @@ async def process_request(
|
|
|
992
658
|
comprehensive_output = "\n".join(output_parts)
|
|
993
659
|
|
|
994
660
|
# Create a wrapper object that mimics AgentRun with the required attributes
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
self.response_state = response_state
|
|
1000
|
-
|
|
1001
|
-
def __getattribute__(self, name):
|
|
1002
|
-
# Handle special attributes first to avoid conflicts
|
|
1003
|
-
if name in ["_wrapped", "_result", "response_state"]:
|
|
1004
|
-
return object.__getattribute__(self, name)
|
|
1005
|
-
|
|
1006
|
-
# Explicitly handle 'result' to return our fallback result
|
|
1007
|
-
if name == "result":
|
|
1008
|
-
return object.__getattribute__(self, "_result")
|
|
1009
|
-
|
|
1010
|
-
# Delegate all other attributes to the wrapped object
|
|
1011
|
-
try:
|
|
1012
|
-
return getattr(object.__getattribute__(self, "_wrapped"), name)
|
|
1013
|
-
except AttributeError:
|
|
1014
|
-
raise AttributeError(
|
|
1015
|
-
f"'{type(self).__name__}' object has no attribute '{name}'"
|
|
1016
|
-
)
|
|
1017
|
-
|
|
1018
|
-
return AgentRunWrapper(agent_run, SimpleResult(comprehensive_output))
|
|
661
|
+
wrapper = AgentRunWrapper(
|
|
662
|
+
agent_run, SimpleResult(comprehensive_output), response_state
|
|
663
|
+
)
|
|
664
|
+
return wrapper
|
|
1019
665
|
|
|
1020
666
|
# For non-fallback cases, we still need to handle the response_state
|
|
1021
667
|
# Create a minimal wrapper just to add response_state
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
except asyncio.CancelledError:
|
|
1042
|
-
# When task is cancelled, raise UserAbortError instead
|
|
1043
|
-
raise UserAbortError("Operation was cancelled by user")
|
|
668
|
+
state_wrapper = AgentRunWithState(agent_run, response_state)
|
|
669
|
+
return state_wrapper
|
|
670
|
+
|
|
671
|
+
except UserAbortError:
|
|
672
|
+
raise
|
|
673
|
+
except ToolBatchingJSONError as e:
|
|
674
|
+
logger.error(f"Tool batching JSON error: {e}", exc_info=True)
|
|
675
|
+
# Patch orphaned tool messages with error
|
|
676
|
+
patch_tool_messages(f"Tool batching failed: {str(e)[:100]}...", state_manager=state_manager)
|
|
677
|
+
# Re-raise to be handled by caller
|
|
678
|
+
raise
|
|
679
|
+
except Exception as e:
|
|
680
|
+
logger.error(f"Error in process_request: {e}", exc_info=True)
|
|
681
|
+
# Patch orphaned tool messages with generic error
|
|
682
|
+
patch_tool_messages(
|
|
683
|
+
f"Request processing failed: {str(e)[:100]}...", state_manager=state_manager
|
|
684
|
+
)
|
|
685
|
+
# Re-raise to be handled by caller
|
|
686
|
+
raise
|