tunacode-cli 0.0.70__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.
- tunacode/cli/commands/__init__.py +0 -2
- tunacode/cli/commands/implementations/__init__.py +0 -3
- tunacode/cli/commands/implementations/debug.py +2 -2
- tunacode/cli/commands/implementations/development.py +10 -8
- tunacode/cli/commands/implementations/model.py +357 -29
- tunacode/cli/commands/implementations/system.py +3 -2
- tunacode/cli/commands/implementations/template.py +0 -2
- tunacode/cli/commands/registry.py +8 -7
- tunacode/cli/commands/slash/loader.py +2 -1
- tunacode/cli/commands/slash/validator.py +2 -1
- tunacode/cli/main.py +19 -1
- tunacode/cli/repl.py +90 -229
- tunacode/cli/repl_components/command_parser.py +2 -1
- tunacode/cli/repl_components/error_recovery.py +8 -5
- tunacode/cli/repl_components/output_display.py +1 -10
- tunacode/cli/repl_components/tool_executor.py +1 -13
- tunacode/configuration/defaults.py +2 -2
- tunacode/configuration/key_descriptions.py +284 -0
- tunacode/configuration/settings.py +0 -1
- tunacode/constants.py +6 -42
- tunacode/core/agents/__init__.py +43 -2
- tunacode/core/agents/agent_components/__init__.py +7 -0
- tunacode/core/agents/agent_components/agent_config.py +162 -158
- tunacode/core/agents/agent_components/agent_helpers.py +31 -2
- tunacode/core/agents/agent_components/node_processor.py +180 -146
- tunacode/core/agents/agent_components/response_state.py +123 -6
- tunacode/core/agents/agent_components/state_transition.py +116 -0
- tunacode/core/agents/agent_components/streaming.py +296 -0
- tunacode/core/agents/agent_components/task_completion.py +19 -6
- tunacode/core/agents/agent_components/tool_buffer.py +21 -1
- tunacode/core/agents/agent_components/tool_executor.py +10 -0
- tunacode/core/agents/main.py +522 -370
- tunacode/core/agents/main_legact.py +538 -0
- tunacode/core/agents/prompts.py +66 -0
- tunacode/core/agents/utils.py +29 -122
- tunacode/core/setup/__init__.py +0 -2
- tunacode/core/setup/config_setup.py +88 -227
- tunacode/core/setup/config_wizard.py +230 -0
- tunacode/core/setup/coordinator.py +2 -1
- tunacode/core/state.py +16 -64
- tunacode/core/token_usage/usage_tracker.py +3 -1
- tunacode/core/tool_authorization.py +352 -0
- tunacode/core/tool_handler.py +67 -60
- tunacode/prompts/system.xml +751 -0
- tunacode/services/mcp.py +97 -1
- tunacode/setup.py +0 -23
- tunacode/tools/base.py +54 -1
- tunacode/tools/bash.py +14 -0
- tunacode/tools/glob.py +4 -2
- tunacode/tools/grep.py +7 -17
- tunacode/tools/prompts/glob_prompt.xml +1 -1
- tunacode/tools/prompts/grep_prompt.xml +1 -0
- tunacode/tools/prompts/list_dir_prompt.xml +1 -1
- tunacode/tools/prompts/react_prompt.xml +23 -0
- tunacode/tools/prompts/read_file_prompt.xml +1 -1
- tunacode/tools/react.py +153 -0
- tunacode/tools/run_command.py +15 -0
- tunacode/types.py +14 -79
- tunacode/ui/completers.py +434 -50
- tunacode/ui/config_dashboard.py +585 -0
- tunacode/ui/console.py +63 -11
- tunacode/ui/input.py +8 -3
- tunacode/ui/keybindings.py +0 -18
- tunacode/ui/model_selector.py +395 -0
- tunacode/ui/output.py +40 -19
- tunacode/ui/panels.py +173 -49
- tunacode/ui/path_heuristics.py +91 -0
- tunacode/ui/prompt_manager.py +1 -20
- tunacode/ui/tool_ui.py +30 -8
- tunacode/utils/api_key_validation.py +93 -0
- tunacode/utils/config_comparator.py +340 -0
- tunacode/utils/models_registry.py +593 -0
- tunacode/utils/text_utils.py +18 -1
- {tunacode_cli-0.0.70.dist-info → tunacode_cli-0.0.78.6.dist-info}/METADATA +80 -12
- {tunacode_cli-0.0.70.dist-info → tunacode_cli-0.0.78.6.dist-info}/RECORD +78 -74
- tunacode/cli/commands/implementations/plan.py +0 -50
- tunacode/cli/commands/implementations/todo.py +0 -217
- tunacode/context.py +0 -71
- tunacode/core/setup/git_safety_setup.py +0 -186
- tunacode/prompts/system.md +0 -359
- tunacode/prompts/system.md.bak +0 -487
- tunacode/tools/exit_plan_mode.py +0 -273
- tunacode/tools/present_plan.py +0 -288
- tunacode/tools/prompts/exit_plan_mode_prompt.xml +0 -25
- tunacode/tools/prompts/present_plan_prompt.xml +0 -20
- tunacode/tools/prompts/todo_prompt.xml +0 -96
- tunacode/tools/todo.py +0 -456
- {tunacode_cli-0.0.70.dist-info → tunacode_cli-0.0.78.6.dist-info}/WHEEL +0 -0
- {tunacode_cli-0.0.70.dist-info → tunacode_cli-0.0.78.6.dist-info}/entry_points.txt +0 -0
- {tunacode_cli-0.0.70.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.
|
|
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
|
|
43
|
-
with reason being one of: "empty", "truncated",
|
|
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 -
|
|
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 {
|
|
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
|
|
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 -
|
|
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
|
-
|
|
153
|
-
f" Iteration {state_manager.session.iteration_count}
|
|
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: '{
|
|
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:
|
|
174
|
+
f"Task completion with pending intentions detected: "
|
|
175
|
+
f"{found_phrases}"
|
|
162
176
|
)
|
|
163
177
|
|
|
164
|
-
# Normal completion
|
|
165
|
-
response_state.
|
|
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
|
|
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,138 +310,147 @@ async def _process_tool_calls(
|
|
|
295
310
|
tool_buffer: Optional[ToolBuffer],
|
|
296
311
|
response_state: Optional[ResponseState],
|
|
297
312
|
) -> None:
|
|
298
|
-
"""
|
|
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
|
-
#
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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"⏸️
|
|
348
|
+
f"⏸️ COLLECTED: {part.tool_name} (will execute in parallel batch)"
|
|
325
349
|
)
|
|
326
350
|
else:
|
|
327
|
-
|
|
328
|
-
if
|
|
329
|
-
|
|
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
|
|
351
|
+
write_execute_tasks.append((part, node))
|
|
352
|
+
if state_manager.session.show_thoughts:
|
|
353
|
+
await ui.muted(
|
|
354
|
+
f"📝 COLLECTED: {part.tool_name} (will execute sequentially)"
|
|
344
355
|
)
|
|
345
356
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
await ui.muted(
|
|
350
|
-
f"🚀 PARALLEL BATCH #{batch_id}: Executing {len(buffered_tasks)} read-only tools concurrently"
|
|
351
|
-
)
|
|
352
|
-
await ui.muted("=" * 60)
|
|
353
|
-
|
|
354
|
-
# Display details of what's being executed
|
|
355
|
-
for idx, (buffered_part, _) in enumerate(buffered_tasks, 1):
|
|
356
|
-
tool_desc = f" [{idx}] {buffered_part.tool_name}"
|
|
357
|
-
if hasattr(buffered_part, "args") and isinstance(
|
|
358
|
-
buffered_part.args, dict
|
|
359
|
-
):
|
|
360
|
-
if (
|
|
361
|
-
buffered_part.tool_name == "read_file"
|
|
362
|
-
and "file_path" in buffered_part.args
|
|
363
|
-
):
|
|
364
|
-
tool_desc += f" → {buffered_part.args['file_path']}"
|
|
365
|
-
elif (
|
|
366
|
-
buffered_part.tool_name == "grep"
|
|
367
|
-
and "pattern" in buffered_part.args
|
|
368
|
-
):
|
|
369
|
-
tool_desc += (
|
|
370
|
-
f" → pattern: '{buffered_part.args['pattern']}'"
|
|
371
|
-
)
|
|
372
|
-
if "include_files" in buffered_part.args:
|
|
373
|
-
tool_desc += (
|
|
374
|
-
f", files: '{buffered_part.args['include_files']}'"
|
|
375
|
-
)
|
|
376
|
-
elif (
|
|
377
|
-
buffered_part.tool_name == "list_dir"
|
|
378
|
-
and "directory" in buffered_part.args
|
|
379
|
-
):
|
|
380
|
-
tool_desc += f" → {buffered_part.args['directory']}"
|
|
381
|
-
elif (
|
|
382
|
-
buffered_part.tool_name == "glob"
|
|
383
|
-
and "pattern" in buffered_part.args
|
|
384
|
-
):
|
|
385
|
-
tool_desc += (
|
|
386
|
-
f" → pattern: '{buffered_part.args['pattern']}'"
|
|
387
|
-
)
|
|
388
|
-
await ui.muted(tool_desc)
|
|
389
|
-
await ui.muted("=" * 60)
|
|
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
|
|
390
360
|
|
|
391
|
-
|
|
361
|
+
batch_id = getattr(state_manager.session, "batch_counter", 0) + 1
|
|
362
|
+
state_manager.session.batch_counter = batch_id
|
|
392
363
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
if not state_manager.is_plan_mode():
|
|
400
|
-
await ui.muted(
|
|
401
|
-
f"✅ Parallel batch completed in {elapsed_time:.0f}ms "
|
|
402
|
-
f"(~{speedup:.1f}x faster than sequential)\n"
|
|
403
|
-
)
|
|
404
|
-
|
|
405
|
-
# Reset spinner message back to thinking
|
|
406
|
-
from tunacode.constants import UI_THINKING_MESSAGE
|
|
407
|
-
|
|
408
|
-
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
|
+
)
|
|
409
369
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
+
)
|
|
413
376
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
+
)
|
|
419
382
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
+
)
|
|
428
428
|
|
|
429
|
-
|
|
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
|
+
)
|
|
430
454
|
|
|
431
455
|
# Track tool calls in session
|
|
432
456
|
if is_processing_tools:
|
|
@@ -440,6 +464,16 @@ async def _process_tool_calls(
|
|
|
440
464
|
}
|
|
441
465
|
state_manager.session.tool_calls.append(tool_info)
|
|
442
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
|
+
|
|
443
477
|
# Update has_user_response based on presence of actual response content
|
|
444
478
|
if (
|
|
445
479
|
response_state
|
|
@@ -1,13 +1,130 @@
|
|
|
1
1
|
"""Response state management for tracking agent processing state."""
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|