tunacode-cli 0.0.55__py3-none-any.whl → 0.0.78.6__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 (114) hide show
  1. tunacode/cli/commands/__init__.py +2 -2
  2. tunacode/cli/commands/implementations/__init__.py +2 -3
  3. tunacode/cli/commands/implementations/command_reload.py +48 -0
  4. tunacode/cli/commands/implementations/debug.py +2 -2
  5. tunacode/cli/commands/implementations/development.py +10 -8
  6. tunacode/cli/commands/implementations/model.py +357 -29
  7. tunacode/cli/commands/implementations/quickstart.py +43 -0
  8. tunacode/cli/commands/implementations/system.py +96 -3
  9. tunacode/cli/commands/implementations/template.py +0 -2
  10. tunacode/cli/commands/registry.py +139 -5
  11. tunacode/cli/commands/slash/__init__.py +32 -0
  12. tunacode/cli/commands/slash/command.py +157 -0
  13. tunacode/cli/commands/slash/loader.py +135 -0
  14. tunacode/cli/commands/slash/processor.py +294 -0
  15. tunacode/cli/commands/slash/types.py +93 -0
  16. tunacode/cli/commands/slash/validator.py +400 -0
  17. tunacode/cli/main.py +23 -2
  18. tunacode/cli/repl.py +217 -190
  19. tunacode/cli/repl_components/command_parser.py +38 -4
  20. tunacode/cli/repl_components/error_recovery.py +85 -4
  21. tunacode/cli/repl_components/output_display.py +12 -1
  22. tunacode/cli/repl_components/tool_executor.py +1 -1
  23. tunacode/configuration/defaults.py +12 -3
  24. tunacode/configuration/key_descriptions.py +284 -0
  25. tunacode/configuration/settings.py +0 -1
  26. tunacode/constants.py +12 -40
  27. tunacode/core/agents/__init__.py +43 -2
  28. tunacode/core/agents/agent_components/__init__.py +7 -0
  29. tunacode/core/agents/agent_components/agent_config.py +249 -55
  30. tunacode/core/agents/agent_components/agent_helpers.py +43 -13
  31. tunacode/core/agents/agent_components/node_processor.py +179 -139
  32. tunacode/core/agents/agent_components/response_state.py +123 -6
  33. tunacode/core/agents/agent_components/state_transition.py +116 -0
  34. tunacode/core/agents/agent_components/streaming.py +296 -0
  35. tunacode/core/agents/agent_components/task_completion.py +19 -6
  36. tunacode/core/agents/agent_components/tool_buffer.py +21 -1
  37. tunacode/core/agents/agent_components/tool_executor.py +10 -0
  38. tunacode/core/agents/main.py +522 -370
  39. tunacode/core/agents/main_legact.py +538 -0
  40. tunacode/core/agents/prompts.py +66 -0
  41. tunacode/core/agents/utils.py +29 -121
  42. tunacode/core/code_index.py +83 -29
  43. tunacode/core/setup/__init__.py +0 -2
  44. tunacode/core/setup/config_setup.py +110 -20
  45. tunacode/core/setup/config_wizard.py +230 -0
  46. tunacode/core/setup/coordinator.py +14 -5
  47. tunacode/core/state.py +16 -20
  48. tunacode/core/token_usage/usage_tracker.py +5 -3
  49. tunacode/core/tool_authorization.py +352 -0
  50. tunacode/core/tool_handler.py +67 -40
  51. tunacode/exceptions.py +119 -5
  52. tunacode/prompts/system.xml +751 -0
  53. tunacode/services/mcp.py +125 -7
  54. tunacode/setup.py +5 -25
  55. tunacode/tools/base.py +163 -0
  56. tunacode/tools/bash.py +110 -1
  57. tunacode/tools/glob.py +332 -34
  58. tunacode/tools/grep.py +179 -82
  59. tunacode/tools/grep_components/result_formatter.py +98 -4
  60. tunacode/tools/list_dir.py +132 -2
  61. tunacode/tools/prompts/bash_prompt.xml +72 -0
  62. tunacode/tools/prompts/glob_prompt.xml +45 -0
  63. tunacode/tools/prompts/grep_prompt.xml +98 -0
  64. tunacode/tools/prompts/list_dir_prompt.xml +31 -0
  65. tunacode/tools/prompts/react_prompt.xml +23 -0
  66. tunacode/tools/prompts/read_file_prompt.xml +54 -0
  67. tunacode/tools/prompts/run_command_prompt.xml +64 -0
  68. tunacode/tools/prompts/update_file_prompt.xml +53 -0
  69. tunacode/tools/prompts/write_file_prompt.xml +37 -0
  70. tunacode/tools/react.py +153 -0
  71. tunacode/tools/read_file.py +91 -0
  72. tunacode/tools/run_command.py +114 -0
  73. tunacode/tools/schema_assembler.py +167 -0
  74. tunacode/tools/update_file.py +94 -0
  75. tunacode/tools/write_file.py +86 -0
  76. tunacode/tools/xml_helper.py +83 -0
  77. tunacode/tutorial/__init__.py +9 -0
  78. tunacode/tutorial/content.py +98 -0
  79. tunacode/tutorial/manager.py +182 -0
  80. tunacode/tutorial/steps.py +124 -0
  81. tunacode/types.py +20 -27
  82. tunacode/ui/completers.py +434 -50
  83. tunacode/ui/config_dashboard.py +585 -0
  84. tunacode/ui/console.py +63 -11
  85. tunacode/ui/input.py +20 -3
  86. tunacode/ui/keybindings.py +7 -4
  87. tunacode/ui/model_selector.py +395 -0
  88. tunacode/ui/output.py +40 -19
  89. tunacode/ui/panels.py +212 -43
  90. tunacode/ui/path_heuristics.py +91 -0
  91. tunacode/ui/prompt_manager.py +5 -1
  92. tunacode/ui/tool_ui.py +33 -10
  93. tunacode/utils/api_key_validation.py +93 -0
  94. tunacode/utils/config_comparator.py +340 -0
  95. tunacode/utils/json_utils.py +206 -0
  96. tunacode/utils/message_utils.py +14 -4
  97. tunacode/utils/models_registry.py +593 -0
  98. tunacode/utils/ripgrep.py +332 -9
  99. tunacode/utils/text_utils.py +18 -1
  100. tunacode/utils/user_configuration.py +45 -0
  101. tunacode_cli-0.0.78.6.dist-info/METADATA +260 -0
  102. tunacode_cli-0.0.78.6.dist-info/RECORD +158 -0
  103. {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.78.6.dist-info}/WHEEL +1 -2
  104. tunacode/cli/commands/implementations/todo.py +0 -217
  105. tunacode/context.py +0 -71
  106. tunacode/core/setup/git_safety_setup.py +0 -182
  107. tunacode/prompts/system.md +0 -731
  108. tunacode/tools/read_file_async_poc.py +0 -196
  109. tunacode/tools/todo.py +0 -349
  110. tunacode_cli-0.0.55.dist-info/METADATA +0 -322
  111. tunacode_cli-0.0.55.dist-info/RECORD +0 -126
  112. tunacode_cli-0.0.55.dist-info/top_level.txt +0 -1
  113. {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.78.6.dist-info}/entry_points.txt +0 -0
  114. {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.78.6.dist-info}/licenses/LICENSE +0 -0
@@ -5,7 +5,8 @@ from typing import Any, Awaitable, Callable, Optional, Tuple
5
5
 
6
6
  from tunacode.core.logging.logger import get_logger
7
7
  from tunacode.core.state import StateManager
8
- from tunacode.types import UsageTrackerProtocol
8
+ from tunacode.exceptions import UserAbortError
9
+ from tunacode.types import AgentState, UsageTrackerProtocol
9
10
  from tunacode.ui.tool_descriptions import get_batch_description, get_tool_description
10
11
 
11
12
  from .response_state import ResponseState
@@ -15,17 +16,6 @@ from .truncation_checker import check_for_truncation
15
16
 
16
17
  logger = get_logger(__name__)
17
18
 
18
- # Import streaming types with fallback for older versions
19
- try:
20
- from pydantic_ai.messages import PartDeltaEvent, TextPartDelta
21
-
22
- STREAMING_AVAILABLE = True
23
- except ImportError:
24
- # Fallback for older pydantic-ai versions
25
- PartDeltaEvent = None
26
- TextPartDelta = None
27
- STREAMING_AVAILABLE = False
28
-
29
19
 
30
20
  async def _process_node(
31
21
  node,
@@ -39,8 +29,9 @@ async def _process_node(
39
29
  """Process a single node from the agent response.
40
30
 
41
31
  Returns:
42
- tuple: (is_empty: bool, reason: Optional[str]) - True if empty/problematic response detected,
43
- with reason being one of: "empty", "truncated", "intention_without_action"
32
+ tuple: (is_empty: bool, reason: Optional[str]) - True if empty/problematic
33
+ response detected, with reason being one of: "empty", "truncated",
34
+ "intention_without_action"
44
35
  """
45
36
  from tunacode.ui import console as ui
46
37
 
@@ -52,6 +43,12 @@ async def _process_node(
52
43
  has_intention = False
53
44
  has_tool_calls = False
54
45
 
46
+ # Transition to ASSISTANT at the start of node processing
47
+ if response_state and response_state.can_transition_to(AgentState.ASSISTANT):
48
+ response_state.transition_to(AgentState.ASSISTANT)
49
+ if state_manager.session.show_thoughts:
50
+ await ui.muted("STATE → ASSISTANT (reasoning)")
51
+
55
52
  if hasattr(node, "request"):
56
53
  state_manager.session.messages.append(node.request)
57
54
 
@@ -93,15 +90,22 @@ async def _process_node(
93
90
  # Agent is trying to complete with pending tools!
94
91
  if state_manager.session.show_thoughts:
95
92
  await ui.warning(
96
- "⚠️ PREMATURE COMPLETION DETECTED - Agent queued tools but marked complete"
93
+ "⚠️ PREMATURE COMPLETION DETECTED - "
94
+ "Agent queued tools but marked complete"
97
95
  )
98
96
  await ui.muted(" Overriding completion to allow tool execution")
99
97
  # Don't mark as complete - let the tools run first
100
98
  # Update the content to remove the marker but don't set task_completed
101
99
  part.content = cleaned_content
102
100
  # Log this as an issue
101
+ pending_tools_count = sum(
102
+ 1
103
+ for p in node.model_response.parts
104
+ if getattr(p, "part_kind", "") == "tool-call"
105
+ )
103
106
  logger.warning(
104
- f"Agent attempted premature completion with {sum(1 for p in node.model_response.parts if getattr(p, 'part_kind', '') == 'tool-call')} pending tools"
107
+ f"Agent attempted premature completion with {pending_tools_count} "
108
+ f"pending tools"
105
109
  )
106
110
  else:
107
111
  # Check if content suggests pending actions
@@ -125,7 +129,8 @@ async def _process_node(
125
129
  phrase in combined_text for phrase in pending_phrases
126
130
  )
127
131
 
128
- # Also check for action verbs at end of content suggesting incomplete action
132
+ # Also check for action verbs at end of content
133
+ # suggesting incomplete action
129
134
  action_endings = [
130
135
  "checking",
131
136
  "searching",
@@ -144,25 +149,37 @@ async def _process_node(
144
149
  # Too early to complete with pending intentions
145
150
  if state_manager.session.show_thoughts:
146
151
  await ui.warning(
147
- "⚠️ SUSPICIOUS COMPLETION - Stated intentions but completing early"
152
+ "⚠️ SUSPICIOUS COMPLETION - "
153
+ "Stated intentions but completing early"
148
154
  )
149
155
  found_phrases = [
150
156
  p for p in pending_phrases if p in combined_text
151
157
  ]
152
- await ui.muted(
153
- f" Iteration {state_manager.session.iteration_count} with pending: {found_phrases}"
158
+ iteration_msg = (
159
+ f" Iteration {state_manager.session.iteration_count} "
160
+ f"with pending: {found_phrases}"
154
161
  )
162
+ await ui.muted(iteration_msg)
155
163
  if ends_with_action:
164
+ action_verb = (
165
+ combined_text.split()[-1]
166
+ if combined_text.split()
167
+ else ""
168
+ )
156
169
  await ui.muted(
157
- f" Content ends with action verb: '{combined_text.split()[-1] if combined_text.split() else ''}'"
170
+ f" Content ends with action verb: '{action_verb}'"
158
171
  )
159
172
  # Still allow it but log warning
160
173
  logger.warning(
161
- f"Task completion with pending intentions detected: {found_phrases}"
174
+ f"Task completion with pending intentions detected: "
175
+ f"{found_phrases}"
162
176
  )
163
177
 
164
- # Normal completion
165
- response_state.task_completed = True
178
+ # Normal completion - transition to RESPONSE state and mark completion
179
+ response_state.transition_to(AgentState.RESPONSE)
180
+ if state_manager.session.show_thoughts:
181
+ await ui.muted("STATE → RESPONSE (completion detected)")
182
+ response_state.set_completion_detected(True)
166
183
  response_state.has_user_response = True
167
184
  # Update the part content to remove the marker
168
185
  part.content = cleaned_content
@@ -175,7 +192,8 @@ async def _process_node(
175
192
  combined_content = " ".join(all_content_parts).strip()
176
193
  appears_truncated = check_for_truncation(combined_content)
177
194
 
178
- # If we only got empty content and no tool calls, we should NOT consider this a valid response
195
+ # If we only got empty content and no tool calls, we should NOT consider this
196
+ # a valid response
179
197
  # This prevents the agent from stopping when it gets empty responses
180
198
  if not has_non_empty_content and not any(
181
199
  hasattr(part, "part_kind") and part.part_kind == "tool-call"
@@ -197,17 +215,6 @@ async def _process_node(
197
215
  await ui.muted("⚠️ TRUNCATED RESPONSE DETECTED - CONTINUING")
198
216
  await ui.muted(f" Last content: ...{combined_content[-100:]}")
199
217
 
200
- # Stream content to callback if provided
201
- # Use this as fallback when true token streaming is not available
202
- if streaming_callback and not STREAMING_AVAILABLE:
203
- for part in node.model_response.parts:
204
- if hasattr(part, "content") and isinstance(part.content, str):
205
- content = part.content.strip()
206
- if content and not content.startswith('{"thought"'):
207
- # Stream non-JSON content (actual response content)
208
- if streaming_callback:
209
- await streaming_callback(content)
210
-
211
218
  # Enhanced display when thoughts are enabled
212
219
  if state_manager.session.show_thoughts:
213
220
  await _display_raw_api_response(node, ui)
@@ -217,6 +224,14 @@ async def _process_node(
217
224
  node, buffering_callback, state_manager, tool_buffer, response_state
218
225
  )
219
226
 
227
+ # If there were no tools and we processed a model response, transition to RESPONSE
228
+ if response_state and response_state.can_transition_to(AgentState.RESPONSE):
229
+ # Only transition if not already completed (set by completion marker path)
230
+ if not response_state.is_completed():
231
+ response_state.transition_to(AgentState.RESPONSE)
232
+ if state_manager.session.show_thoughts:
233
+ await ui.muted("STATE → RESPONSE (handled output)")
234
+
220
235
  # Determine empty response reason
221
236
  if empty_response_detected:
222
237
  if appears_truncated:
@@ -295,132 +310,147 @@ async def _process_tool_calls(
295
310
  tool_buffer: Optional[ToolBuffer],
296
311
  response_state: Optional[ResponseState],
297
312
  ) -> None:
298
- """Process tool calls from the node."""
313
+ """
314
+ Process tool calls from the node using smart batching strategy.
315
+
316
+ Smart batching optimization:
317
+ - Collect all read-only tools into a single batch (regardless of write tools in between)
318
+ - Execute all read-only tools in one parallel batch
319
+ - Execute write/execute tools sequentially in their original order
320
+
321
+ This maximizes parallel execution efficiency by avoiding premature buffer flushes.
322
+ """
299
323
  from tunacode.constants import READ_ONLY_TOOLS
300
324
  from tunacode.ui import console as ui
301
325
 
302
326
  # Track if we're processing tool calls
303
327
  is_processing_tools = False
304
328
 
305
- # Process tool calls
329
+ # Phase 1: Collect and categorize all tools
330
+ read_only_tasks = []
331
+ write_execute_tasks = []
332
+
306
333
  for part in node.model_response.parts:
307
334
  if hasattr(part, "part_kind") and part.part_kind == "tool-call":
308
335
  is_processing_tools = True
309
- if tool_callback:
310
- # Check if this is a read-only tool that can be batched
311
- if tool_buffer is not None and part.tool_name in READ_ONLY_TOOLS:
312
- # Add to buffer instead of executing immediately
313
- tool_buffer.add(part, node)
314
-
315
- # Update spinner to show we're collecting tools
316
- buffered_count = len(tool_buffer.read_only_tasks)
317
- await ui.update_spinner_message(
318
- f"[bold #00d7ff]Collecting tools ({buffered_count} buffered)...[/bold #00d7ff]",
319
- state_manager,
320
- )
336
+ # Transition to TOOL_EXECUTION on first tool call
337
+ if response_state and response_state.can_transition_to(AgentState.TOOL_EXECUTION):
338
+ response_state.transition_to(AgentState.TOOL_EXECUTION)
339
+ if state_manager.session.show_thoughts:
340
+ await ui.muted("STATE → TOOL_EXECUTION (executing tools)")
321
341
 
342
+ if tool_callback:
343
+ # Categorize: read-only vs write/execute
344
+ if part.tool_name in READ_ONLY_TOOLS:
345
+ read_only_tasks.append((part, node))
322
346
  if state_manager.session.show_thoughts:
323
347
  await ui.muted(
324
- f"⏸️ BUFFERED: {part.tool_name} (will execute in parallel batch)"
348
+ f"⏸️ COLLECTED: {part.tool_name} (will execute in parallel batch)"
325
349
  )
326
350
  else:
327
- # Write/execute tool - process any buffered reads first
328
- if tool_buffer is not None and tool_buffer.has_tasks():
329
- import time
330
-
331
- from .tool_executor import execute_tools_parallel
332
-
333
- buffered_tasks = tool_buffer.flush()
334
- batch_id = getattr(state_manager.session, "batch_counter", 0) + 1
335
- state_manager.session.batch_counter = batch_id
336
-
337
- start_time = time.time()
338
-
339
- # Update spinner message for batch execution
340
- tool_names = [part.tool_name for part, _ in buffered_tasks]
341
- batch_msg = get_batch_description(len(buffered_tasks), tool_names)
342
- await ui.update_spinner_message(
343
- f"[bold #00d7ff]{batch_msg}...[/bold #00d7ff]", state_manager
344
- )
345
-
346
- # Enhanced visual feedback for parallel execution
347
- await ui.muted("\n" + "=" * 60)
351
+ write_execute_tasks.append((part, node))
352
+ if state_manager.session.show_thoughts:
348
353
  await ui.muted(
349
- f"🚀 PARALLEL BATCH #{batch_id}: Executing {len(buffered_tasks)} read-only tools concurrently"
354
+ f"📝 COLLECTED: {part.tool_name} (will execute sequentially)"
350
355
  )
351
- await ui.muted("=" * 60)
352
-
353
- # Display details of what's being executed
354
- for idx, (buffered_part, _) in enumerate(buffered_tasks, 1):
355
- tool_desc = f" [{idx}] {buffered_part.tool_name}"
356
- if hasattr(buffered_part, "args") and isinstance(
357
- buffered_part.args, dict
358
- ):
359
- if (
360
- buffered_part.tool_name == "read_file"
361
- and "file_path" in buffered_part.args
362
- ):
363
- tool_desc += f" → {buffered_part.args['file_path']}"
364
- elif (
365
- buffered_part.tool_name == "grep"
366
- and "pattern" in buffered_part.args
367
- ):
368
- tool_desc += f" → pattern: '{buffered_part.args['pattern']}'"
369
- if "include_files" in buffered_part.args:
370
- tool_desc += (
371
- f", files: '{buffered_part.args['include_files']}'"
372
- )
373
- elif (
374
- buffered_part.tool_name == "list_dir"
375
- and "directory" in buffered_part.args
376
- ):
377
- tool_desc += f" → {buffered_part.args['directory']}"
378
- elif (
379
- buffered_part.tool_name == "glob"
380
- and "pattern" in buffered_part.args
381
- ):
382
- tool_desc += f" → pattern: '{buffered_part.args['pattern']}'"
383
- await ui.muted(tool_desc)
384
- await ui.muted("=" * 60)
385
-
386
- await execute_tools_parallel(buffered_tasks, tool_callback)
387
-
388
- elapsed_time = (time.time() - start_time) * 1000
389
- sequential_estimate = (
390
- len(buffered_tasks) * 100
391
- ) # Assume 100ms per tool average
392
- speedup = sequential_estimate / elapsed_time if elapsed_time > 0 else 1.0
393
356
 
394
- await ui.muted(
395
- f"✅ Parallel batch completed in {elapsed_time:.0f}ms "
396
- f"(~{speedup:.1f}x faster than sequential)\n"
397
- )
357
+ # Phase 2: Execute read-only tools in ONE parallel batch
358
+ if read_only_tasks and tool_callback:
359
+ from .tool_executor import execute_tools_parallel
398
360
 
399
- # Reset spinner message back to thinking
400
- from tunacode.constants import UI_THINKING_MESSAGE
361
+ batch_id = getattr(state_manager.session, "batch_counter", 0) + 1
362
+ state_manager.session.batch_counter = batch_id
401
363
 
402
- await ui.update_spinner_message(UI_THINKING_MESSAGE, state_manager)
364
+ # Update spinner to show batch collection
365
+ await ui.update_spinner_message(
366
+ f"[bold #00d7ff]Collected {len(read_only_tasks)} read-only tools...[/bold #00d7ff]",
367
+ state_manager,
368
+ )
403
369
 
404
- # Now execute the write/execute tool
405
- if state_manager.session.show_thoughts:
406
- await ui.warning(f"⚠️ SEQUENTIAL: {part.tool_name} (write/execute tool)")
370
+ # Update spinner message for batch execution
371
+ tool_names = [part.tool_name for part, _ in read_only_tasks]
372
+ batch_msg = get_batch_description(len(read_only_tasks), tool_names)
373
+ await ui.update_spinner_message(
374
+ f"[bold #00d7ff]{batch_msg}...[/bold #00d7ff]", state_manager
375
+ )
407
376
 
408
- # Update spinner for sequential tool
409
- tool_args = getattr(part, "args", {}) if hasattr(part, "args") else {}
410
- # Parse args if they're a JSON string
411
- if isinstance(tool_args, str):
412
- import json
377
+ # Build batch content as markdown for Rich panel
378
+ batch_content = (
379
+ f"**PARALLEL BATCH #{batch_id}**: "
380
+ f"Executing {len(read_only_tasks)} read-only tools concurrently\n\n"
381
+ )
413
382
 
414
- try:
415
- tool_args = json.loads(tool_args)
416
- except (json.JSONDecodeError, TypeError):
417
- tool_args = {}
418
- tool_desc = get_tool_description(part.tool_name, tool_args)
419
- await ui.update_spinner_message(
420
- f"[bold #00d7ff]{tool_desc}...[/bold #00d7ff]", state_manager
421
- )
383
+ # Display details of what's being executed
384
+ for idx, (part, _) in enumerate(read_only_tasks, 1):
385
+ tool_desc = f" **[{idx}]** `{part.tool_name}`"
386
+ if hasattr(part, "args") and isinstance(part.args, dict):
387
+ if part.tool_name == "read_file" and "file_path" in part.args:
388
+ tool_desc += f" → `{part.args['file_path']}`"
389
+ elif part.tool_name == "grep" and "pattern" in part.args:
390
+ tool_desc += f" → pattern: `{part.args['pattern']}`"
391
+ if "include_files" in part.args:
392
+ tool_desc += f", files: `{part.args['include_files']}`"
393
+ elif part.tool_name == "list_dir" and "directory" in part.args:
394
+ tool_desc += f" → `{part.args['directory']}`"
395
+ elif part.tool_name == "glob" and "pattern" in part.args:
396
+ tool_desc += f" → pattern: `{part.args['pattern']}`"
397
+ batch_content += f"{tool_desc}\n"
398
+
399
+ await execute_tools_parallel(read_only_tasks, tool_callback)
400
+
401
+ # Display batch execution in green Rich panel
402
+ await ui.batch(batch_content)
403
+
404
+ # Reset spinner message back to thinking
405
+ from tunacode.constants import UI_THINKING_MESSAGE
406
+
407
+ await ui.update_spinner_message(UI_THINKING_MESSAGE, state_manager)
408
+
409
+ # Phase 3: Execute write/execute tools sequentially
410
+ for part, node in write_execute_tasks:
411
+ if state_manager.session.show_thoughts:
412
+ await ui.warning(f"⚠️ SEQUENTIAL: {part.tool_name} (write/execute tool)")
413
+
414
+ # Update spinner for sequential tool
415
+ tool_args = getattr(part, "args", {}) if hasattr(part, "args") else {}
416
+ # Parse args if they're a JSON string
417
+ if isinstance(tool_args, str):
418
+ import json
419
+
420
+ try:
421
+ tool_args = json.loads(tool_args)
422
+ except (json.JSONDecodeError, TypeError):
423
+ tool_args = {}
424
+ tool_desc = get_tool_description(part.tool_name, tool_args)
425
+ await ui.update_spinner_message(
426
+ f"[bold #00d7ff]{tool_desc}...[/bold #00d7ff]", state_manager
427
+ )
422
428
 
423
- await tool_callback(part, node)
429
+ # Execute the tool with robust error handling
430
+ try:
431
+ await tool_callback(part, node)
432
+ except UserAbortError:
433
+ raise
434
+ except Exception as tool_err:
435
+ logger.error(
436
+ "Tool callback failed: tool=%s iter=%s err=%s",
437
+ getattr(part, "tool_name", "<unknown>"),
438
+ getattr(state_manager.session, "current_iteration", "?"),
439
+ tool_err,
440
+ exc_info=True,
441
+ )
442
+ # Surface to UI when thoughts are enabled, then continue gracefully
443
+ if getattr(state_manager.session, "show_thoughts", False):
444
+ await ui.warning(
445
+ f"❌ Tool failed: {getattr(part, 'tool_name', '<unknown>')} — continuing"
446
+ )
447
+ finally:
448
+ # Tool execution completed - resource cleanup handled by BaseTool.execute()
449
+ tool_name = getattr(part, "tool_name", "<unknown>")
450
+ logger.debug(
451
+ "Tool execution completed (success or failure): tool=%s",
452
+ tool_name,
453
+ )
424
454
 
425
455
  # Track tool calls in session
426
456
  if is_processing_tools:
@@ -434,6 +464,16 @@ async def _process_tool_calls(
434
464
  }
435
465
  state_manager.session.tool_calls.append(tool_info)
436
466
 
467
+ # After tools are processed, transition back to RESPONSE
468
+ if (
469
+ is_processing_tools
470
+ and response_state
471
+ and response_state.can_transition_to(AgentState.RESPONSE)
472
+ ):
473
+ response_state.transition_to(AgentState.RESPONSE)
474
+ if state_manager.session.show_thoughts:
475
+ await ui.muted("STATE → RESPONSE (tools finished)")
476
+
437
477
  # Update has_user_response based on presence of actual response content
438
478
  if (
439
479
  response_state
@@ -1,13 +1,130 @@
1
1
  """Response state management for tracking agent processing state."""
2
2
 
3
- from dataclasses import dataclass
3
+ import threading
4
+ from dataclasses import dataclass, field
5
+ from typing import Optional
6
+
7
+ from tunacode.types import AgentState
8
+
9
+ from .state_transition import AGENT_TRANSITION_RULES, AgentStateMachine
4
10
 
5
11
 
6
12
  @dataclass
7
13
  class ResponseState:
8
- """Track state across agent response processing."""
14
+ """Enhanced response state using enum-based state machine."""
15
+
16
+ # Internal state machine
17
+ _state_machine: AgentStateMachine = field(
18
+ default_factory=lambda: AgentStateMachine(AgentState.USER_INPUT, AGENT_TRANSITION_RULES)
19
+ )
20
+
21
+ # Backward compatibility boolean flags (derived from enum state)
22
+ _has_user_response: bool = False
23
+ _task_completed: bool = False
24
+ _awaiting_user_guidance: bool = False
25
+ _has_final_synthesis: bool = False
26
+ # Thread-safe lock for boolean flag access
27
+ _lock: threading.RLock = field(default_factory=threading.RLock, init=False, repr=False)
28
+
29
+ def __post_init__(self):
30
+ """Initialize the state machine."""
31
+ if not hasattr(self, "_state_machine"):
32
+ self._state_machine = AgentStateMachine(AgentState.USER_INPUT, AGENT_TRANSITION_RULES)
33
+ if not hasattr(self, "_lock"):
34
+ self._lock = threading.RLock()
35
+
36
+ @property
37
+ def current_state(self) -> AgentState:
38
+ """Get the current enum state."""
39
+ return self._state_machine.current_state
40
+
41
+ def transition_to(self, new_state: AgentState) -> None:
42
+ """Transition to a new state."""
43
+ self._state_machine.transition_to(new_state)
44
+
45
+ def can_transition_to(self, target_state: AgentState) -> bool:
46
+ """Check if a transition to the target state is allowed."""
47
+ return self._state_machine.can_transition_to(target_state)
48
+
49
+ # Backward compatibility properties
50
+ @property
51
+ def has_user_response(self) -> bool:
52
+ """Legacy boolean flag for user response detection."""
53
+ with self._lock:
54
+ return self._has_user_response
55
+
56
+ @has_user_response.setter
57
+ def has_user_response(self, value: bool) -> None:
58
+ """Set the legacy has_user_response flag."""
59
+ with self._lock:
60
+ self._has_user_response = value
61
+
62
+ @property
63
+ def task_completed(self) -> bool:
64
+ """Legacy boolean flag for task completion (derived from state machine)."""
65
+ with self._lock:
66
+ # If explicitly set true, honor it; otherwise derive from state machine
67
+ return bool(self._task_completed or self._state_machine.is_completed())
68
+
69
+ @task_completed.setter
70
+ def task_completed(self, value: bool) -> None:
71
+ """Set the legacy task_completed flag and sync with state machine."""
72
+ with self._lock:
73
+ self._task_completed = bool(value)
74
+ if value:
75
+ # Ensure state reflects completion in RESPONSE
76
+ try:
77
+ if (
78
+ self._state_machine.current_state != AgentState.RESPONSE
79
+ and self._state_machine.can_transition_to(AgentState.RESPONSE)
80
+ ):
81
+ self._state_machine.transition_to(AgentState.RESPONSE)
82
+ except Exception:
83
+ # Best-effort: ignore invalid transition in legacy paths
84
+ pass
85
+ self._state_machine.set_completion_detected(True)
86
+ else:
87
+ self._state_machine.set_completion_detected(False)
88
+
89
+ @property
90
+ def awaiting_user_guidance(self) -> bool:
91
+ """Legacy boolean flag for awaiting user guidance."""
92
+ with self._lock:
93
+ return self._awaiting_user_guidance
94
+
95
+ @awaiting_user_guidance.setter
96
+ def awaiting_user_guidance(self, value: bool) -> None:
97
+ """Set the legacy awaiting_user_guidance flag."""
98
+ with self._lock:
99
+ self._awaiting_user_guidance = value
100
+
101
+ @property
102
+ def has_final_synthesis(self) -> bool:
103
+ """Legacy boolean flag for final synthesis."""
104
+ with self._lock:
105
+ return self._has_final_synthesis
106
+
107
+ @has_final_synthesis.setter
108
+ def has_final_synthesis(self, value: bool) -> None:
109
+ """Set the legacy has_final_synthesis flag."""
110
+ with self._lock:
111
+ self._has_final_synthesis = value
112
+
113
+ # Enhanced state management methods
114
+ def set_completion_detected(self, detected: bool = True) -> None:
115
+ """Mark that completion has been detected in the RESPONSE state."""
116
+ self._state_machine.set_completion_detected(detected)
117
+
118
+ def is_completed(self) -> bool:
119
+ """Check if the task is completed according to the state machine."""
120
+ return self._state_machine.is_completed()
9
121
 
10
- has_user_response: bool = False
11
- task_completed: bool = False
12
- awaiting_user_guidance: bool = False
13
- has_final_synthesis: bool = False
122
+ def reset_state(self, initial_state: Optional[AgentState] = None) -> None:
123
+ """Reset the state machine to initial state."""
124
+ with self._lock:
125
+ self._state_machine.reset(initial_state)
126
+ # Reset legacy flags
127
+ self._has_user_response = False
128
+ self._task_completed = False
129
+ self._awaiting_user_guidance = False
130
+ self._has_final_synthesis = False