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.

Files changed (87) hide show
  1. tunacode/cli/commands/base.py +2 -2
  2. tunacode/cli/commands/implementations/__init__.py +7 -1
  3. tunacode/cli/commands/implementations/conversation.py +1 -1
  4. tunacode/cli/commands/implementations/debug.py +1 -1
  5. tunacode/cli/commands/implementations/development.py +4 -1
  6. tunacode/cli/commands/implementations/template.py +132 -0
  7. tunacode/cli/commands/registry.py +28 -1
  8. tunacode/cli/commands/template_shortcut.py +93 -0
  9. tunacode/cli/main.py +6 -0
  10. tunacode/cli/repl.py +29 -174
  11. tunacode/cli/repl_components/__init__.py +10 -0
  12. tunacode/cli/repl_components/command_parser.py +34 -0
  13. tunacode/cli/repl_components/error_recovery.py +88 -0
  14. tunacode/cli/repl_components/output_display.py +33 -0
  15. tunacode/cli/repl_components/tool_executor.py +84 -0
  16. tunacode/configuration/defaults.py +2 -2
  17. tunacode/configuration/settings.py +11 -14
  18. tunacode/constants.py +57 -23
  19. tunacode/context.py +0 -14
  20. tunacode/core/agents/agent_components/__init__.py +27 -0
  21. tunacode/core/agents/agent_components/agent_config.py +109 -0
  22. tunacode/core/agents/agent_components/json_tool_parser.py +109 -0
  23. tunacode/core/agents/agent_components/message_handler.py +100 -0
  24. tunacode/core/agents/agent_components/node_processor.py +480 -0
  25. tunacode/core/agents/agent_components/response_state.py +13 -0
  26. tunacode/core/agents/agent_components/result_wrapper.py +50 -0
  27. tunacode/core/agents/agent_components/task_completion.py +28 -0
  28. tunacode/core/agents/agent_components/tool_buffer.py +24 -0
  29. tunacode/core/agents/agent_components/tool_executor.py +49 -0
  30. tunacode/core/agents/main.py +421 -778
  31. tunacode/core/agents/utils.py +42 -2
  32. tunacode/core/background/manager.py +3 -3
  33. tunacode/core/logging/__init__.py +4 -3
  34. tunacode/core/logging/config.py +29 -16
  35. tunacode/core/logging/formatters.py +1 -1
  36. tunacode/core/logging/handlers.py +41 -7
  37. tunacode/core/setup/__init__.py +2 -0
  38. tunacode/core/setup/agent_setup.py +2 -2
  39. tunacode/core/setup/base.py +2 -2
  40. tunacode/core/setup/config_setup.py +10 -6
  41. tunacode/core/setup/git_safety_setup.py +13 -2
  42. tunacode/core/setup/template_setup.py +75 -0
  43. tunacode/core/state.py +13 -2
  44. tunacode/core/token_usage/api_response_parser.py +6 -2
  45. tunacode/core/token_usage/usage_tracker.py +37 -7
  46. tunacode/core/tool_handler.py +24 -1
  47. tunacode/prompts/system.md +289 -4
  48. tunacode/setup.py +2 -0
  49. tunacode/templates/__init__.py +9 -0
  50. tunacode/templates/loader.py +210 -0
  51. tunacode/tools/glob.py +3 -3
  52. tunacode/tools/grep.py +26 -276
  53. tunacode/tools/grep_components/__init__.py +9 -0
  54. tunacode/tools/grep_components/file_filter.py +93 -0
  55. tunacode/tools/grep_components/pattern_matcher.py +152 -0
  56. tunacode/tools/grep_components/result_formatter.py +45 -0
  57. tunacode/tools/grep_components/search_result.py +35 -0
  58. tunacode/tools/todo.py +27 -21
  59. tunacode/types.py +19 -4
  60. tunacode/ui/completers.py +6 -1
  61. tunacode/ui/decorators.py +2 -2
  62. tunacode/ui/keybindings.py +1 -1
  63. tunacode/ui/panels.py +13 -5
  64. tunacode/ui/prompt_manager.py +1 -1
  65. tunacode/ui/tool_ui.py +8 -2
  66. tunacode/utils/bm25.py +4 -4
  67. tunacode/utils/file_utils.py +2 -2
  68. tunacode/utils/message_utils.py +3 -1
  69. tunacode/utils/system.py +0 -4
  70. tunacode/utils/text_utils.py +1 -1
  71. tunacode/utils/token_counter.py +2 -2
  72. {tunacode_cli-0.0.50.dist-info → tunacode_cli-0.0.53.dist-info}/METADATA +146 -1
  73. tunacode_cli-0.0.53.dist-info/RECORD +123 -0
  74. {tunacode_cli-0.0.50.dist-info → tunacode_cli-0.0.53.dist-info}/top_level.txt +0 -1
  75. api/auth.py +0 -13
  76. api/users.py +0 -8
  77. tunacode/core/recursive/__init__.py +0 -18
  78. tunacode/core/recursive/aggregator.py +0 -467
  79. tunacode/core/recursive/budget.py +0 -414
  80. tunacode/core/recursive/decomposer.py +0 -398
  81. tunacode/core/recursive/executor.py +0 -470
  82. tunacode/core/recursive/hierarchy.py +0 -488
  83. tunacode/ui/recursive_progress.py +0 -380
  84. tunacode_cli-0.0.50.dist-info/RECORD +0 -107
  85. {tunacode_cli-0.0.50.dist-info → tunacode_cli-0.0.53.dist-info}/WHEEL +0 -0
  86. {tunacode_cli-0.0.50.dist-info → tunacode_cli-0.0.53.dist-info}/entry_points.txt +0 -0
  87. {tunacode_cli-0.0.50.dist-info → tunacode_cli-0.0.53.dist-info}/licenses/LICENSE +0 -0
@@ -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
- import asyncio
8
- import json
9
- import os
10
- import re
11
- from datetime import datetime, timezone
12
- from pathlib import Path
13
- from typing import Any, Iterator, List, Optional, Tuple
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
- def __init__(self):
70
- self.read_only_tasks: List[Tuple[Any, Any]] = []
87
+ def _get_user_prompt_part_class():
88
+ """Get UserPromptPart class with caching and fallback for test environment.
71
89
 
72
- def add(self, part: Any, node: Any) -> None:
73
- """Add a read-only tool call to the buffer."""
74
- self.read_only_tasks.append((part, node))
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
- def flush(self) -> List[Tuple[Any, Any]]:
77
- """Return buffered tasks and clear the buffer."""
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
- def has_tasks(self) -> bool:
83
- """Check if there are buffered tasks."""
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
- # Lazy import for Agent and Tool
88
- def get_agent_tool():
89
- import importlib
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
- pydantic_ai = importlib.import_module("pydantic_ai")
92
- return pydantic_ai.Agent, pydantic_ai.Tool
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
- def get_model_messages():
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
- async def execute_tools_parallel(
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
- Returns:
114
- List of results in the same order as input, with exceptions for failed calls
115
- """
116
- # Get max parallel from environment or default to CPU count
117
- max_parallel = int(os.environ.get("TUNACODE_MAX_PARALLEL", os.cpu_count() or 4))
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
- Batch tool calls so read-only tools can be executed in parallel.
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
- Args:
150
- tool_calls: List of tool call objects with 'tool' attribute
151
-
152
- Yields:
153
- Batches of tool calls
140
+ Returns:
141
+ bool: True if query is satisfied, False otherwise
154
142
  """
155
- if not tool_calls:
156
- return
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
- # Yield any remaining read-only tools
176
- if current_batch:
177
- yield current_batch
178
-
179
-
180
- async def create_buffering_callback(
181
- original_callback: ToolCallback, buffer: ToolBuffer, state_manager: StateManager
182
- ) -> ToolCallback:
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
- Create a callback wrapper that buffers read-only tools for parallel execution.
158
+ Process a single request to the agent.
185
159
 
186
160
  Args:
187
- original_callback: The original tool callback
188
- buffer: ToolBuffer instance to store read-only tools
189
- state_manager: StateManager for UI access
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
- A wrapped callback function
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
- async def buffering_callback(part, node):
196
- tool_name = getattr(part, "tool_name", None)
175
+ # Create a unique request ID for debugging
176
+ import uuid
197
177
 
198
- if tool_name in READ_ONLY_TOOLS:
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
- # Non-read-only tool encountered - flush buffer first
205
- if buffer.has_tasks():
206
- buffered_tasks = buffer.flush()
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
- # Execute buffered read-only tools in parallel
209
- if state_manager.session.show_thoughts:
210
- from tunacode.ui import console as ui
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
- await ui.muted(f"Executing {len(buffered_tasks)} read-only tools in parallel")
189
+ # Create tool buffer for parallel execution
190
+ tool_buffer = ToolBuffer()
213
191
 
214
- await execute_tools_parallel(buffered_tasks, original_callback)
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
- # Execute the non-read-only tool
217
- return await original_callback(part, node)
197
+ # Track response state
198
+ response_state = ResponseState()
218
199
 
219
- return buffering_callback
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
- async def _process_node(
223
- node,
224
- tool_callback: Optional[ToolCallback],
225
- state_manager: StateManager,
226
- tool_buffer: Optional[ToolBuffer] = None,
227
- streaming_callback: Optional[callable] = None,
228
- usage_tracker: Optional[UsageTrackerProtocol] = None,
229
- ):
230
- from tunacode.ui import console as ui
231
- from tunacode.utils.token_counter import estimate_tokens
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
- # Track this tool call (moved outside thoughts block)
368
- state_manager.session.tool_calls.append(
369
- {
370
- "tool": part.tool_name,
371
- "args": part.args if hasattr(part, "args") else {},
372
- "iteration": state_manager.session.current_iteration,
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
- # Track files if this is read_file (moved outside thoughts block)
377
- if (
378
- part.tool_name == "read_file"
379
- and hasattr(part, "args")
380
- and isinstance(part.args, dict)
381
- and "file_path" in part.args
382
- ):
383
- state_manager.session.files_in_context.add(part.args["file_path"])
384
- # Show files in context when thoughts are enabled
385
- if state_manager.session.show_thoughts:
386
- await ui.muted(
387
- f"\nFILES IN CONTEXT: {list(state_manager.session.files_in_context)}"
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
- # Execute tool calls - with ACTUAL parallel execution for read-only batches
391
- if tool_parts:
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
- # TODO: Currently only batches if ALL tools are read-only. Should be updated to use
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
- start_time = time.time()
269
+ Task: {message[:200]}...
270
+ {tools_context}
271
+ Current iteration: {i}
410
272
 
411
- if state_manager.session.show_thoughts:
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
- if state_manager.session.show_thoughts:
437
- elapsed_time = (time.time() - start_time) * 1000
438
- sequential_estimate = len(tool_parts) * 100
439
- speedup = sequential_estimate / elapsed_time if elapsed_time > 0 else 1.0
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
- else:
445
- # Sequential execution for mixed or write/execute tools
446
- for part in tool_parts:
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
- # Display tool return when thoughts are enabled
465
- if state_manager.session.show_thoughts:
466
- # Truncate for display
467
- display_content = (
468
- part.content[:200] + "..." if len(part.content) > 200 else part.content
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
- except ToolBatchingJSONError as e:
481
- # Handle JSON parsing failure after retries
482
- logger.error(f"Tool batching JSON error: {e}")
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
- except Exception as e:
693
- if state_manager.session.show_thoughts:
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
- await ui.error(f"Error executing fallback tool {tool_name}: {str(e)}")
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
- async def extract_and_execute_tool_calls(
700
- text: str, tool_callback: Optional[ToolCallback], state_manager: StateManager
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
- # Format 2: Tool calls in code blocks
713
- code_block_pattern = r'```json\s*(\{(?:[^{}]|"[^"]*"|(?:\{[^}]*\}))*"tool"(?:[^{}]|"[^"]*"|(?:\{[^}]*\}))*\})\s*```'
714
- code_matches = re.findall(code_block_pattern, text, re.MULTILINE | re.DOTALL)
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
- for match in code_matches:
717
- try:
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
- class MockToolCall:
722
- def __init__(self, tool_name: str, args: dict):
723
- self.tool_name = tool_name
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
- class MockNode:
728
- pass
370
+ # Reset counter after intervention
371
+ unproductive_iterations = 0
729
372
 
730
- mock_call = MockToolCall(tool_data["tool"], tool_data["args"])
731
- mock_node = MockNode()
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
- await tool_callback(mock_call, mock_node)
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"FALLBACK: Executed {tool_data['tool']} from code block")
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
- async def process_request(
748
- model: ModelName,
749
- message: str,
750
- state_manager: StateManager,
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
- from tunacode.configuration.models import ModelRegistry
766
- from tunacode.core.token_usage.usage_tracker import UsageTracker
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
- parser = ApiResponseParser()
769
- registry = ModelRegistry()
770
- calculator = CostCalculator(registry)
771
- usage_tracker = UsageTracker(parser, calculator, state_manager)
772
- response_state = ResponseState()
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
- # Reset iteration tracking for this request
775
- state_manager.session.iteration_count = 0
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
- # Create a request-level buffer for batching read-only tools across nodes
778
- tool_buffer = ToolBuffer()
419
+ clarification_content = f"""I need clarification to continue.
779
420
 
780
- # Show TUNACODE.md preview if it was loaded and thoughts are enabled
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
- await ui.muted(state_manager.tunacode_preview)
785
- # Clear the preview after displaying it once
786
- delattr(state_manager, "tunacode_preview")
423
+ Progress so far:
424
+ - Iterations: {i}
425
+ - Tools used: {tools_used_str}
787
426
 
788
- # Show what we're sending to the API when thoughts are enabled
789
- if state_manager.session.show_thoughts:
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
- await ui.muted("\n" + "=" * 60)
793
- await ui.muted("📤 SENDING TO API:")
794
- await ui.muted(f"Message: {message}")
795
- await ui.muted(f"Model: {model}")
796
- await ui.muted(f"Message History Length: {len(mh)}")
797
- await ui.muted("=" * 60)
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
- async with agent.iter(message, message_history=mh) as agent_run:
800
- i = 0
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
- # Handle token-level streaming for model request nodes
805
- if streaming_callback and STREAMING_AVAILABLE and Agent.is_model_request_node(node):
806
- async with node.stream(agent_run.ctx) as request_stream:
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
- await _process_node(
816
- node,
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
- # Display iteration progress if thoughts are enabled
830
- if state_manager.session.show_thoughts:
831
- from tunacode.ui import console as ui
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
- await ui.muted(f"\nITERATION: {i}/{max_iterations}")
455
+ await ui.success("Task completed successfully")
456
+ break
834
457
 
835
- # Show summary of tools used so far
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
- summary_str = ", ".join(
843
- [f"{name}: {count}" for name, count in tool_summary.items()]
844
- )
845
- await ui.muted(f"TOOLS USED: {summary_str}")
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.warning(f"Reached maximum iterations ({max_iterations})")
852
- break
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
- if not response_state.has_user_response and i >= max_iterations and fallback_enabled:
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
- class AgentRunWrapper:
996
- def __init__(self, wrapped_run, fallback_result):
997
- self._wrapped = wrapped_run
998
- self._result = fallback_result
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
- class AgentRunWithState:
1023
- def __init__(self, wrapped_run):
1024
- self._wrapped = wrapped_run
1025
- self.response_state = response_state
1026
-
1027
- def __getattribute__(self, name):
1028
- # Handle special attributes first
1029
- if name in ["_wrapped", "response_state"]:
1030
- return object.__getattribute__(self, name)
1031
-
1032
- # Delegate all other attributes to the wrapped object
1033
- try:
1034
- return getattr(object.__getattribute__(self, "_wrapped"), name)
1035
- except AttributeError:
1036
- raise AttributeError(
1037
- f"'{type(self).__name__}' object has no attribute '{name}'"
1038
- )
1039
-
1040
- return AgentRunWithState(agent_run)
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