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
@@ -1,12 +1,22 @@
1
1
  """Module: tunacode.core.agents.main
2
2
 
3
- Main agent functionality and coordination for the TunaCode CLI.
3
+ Refactored main agent functionality with focused responsibility classes.
4
4
  Handles agent creation, configuration, and request processing.
5
5
 
6
6
  CLAUDE_ANCHOR[main-agent-module]: Primary agent orchestration and lifecycle management
7
+
8
+ This is a refactored version of old main.py that:
9
+ - Eliminates StateFacade in favor of direct SessionState access
10
+ - Extracts intervention logic into focused classes
11
+ - Centralizes prompts in prompts.py module
7
12
  """
8
13
 
9
- from typing import TYPE_CHECKING, Awaitable, Callable, Optional
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import uuid
18
+ from dataclasses import dataclass
19
+ from typing import TYPE_CHECKING, Any, Awaitable, Callable, Optional
10
20
 
11
21
  from pydantic_ai import Agent
12
22
 
@@ -16,445 +26,587 @@ if TYPE_CHECKING:
16
26
  from tunacode.core.logging.logger import get_logger
17
27
  from tunacode.core.state import StateManager
18
28
  from tunacode.exceptions import ToolBatchingJSONError, UserAbortError
19
- from tunacode.services.mcp import get_mcp_servers
29
+ from tunacode.services.mcp import ( # re-exported by design
30
+ cleanup_mcp_servers,
31
+ get_mcp_servers,
32
+ register_mcp_agent,
33
+ )
34
+ from tunacode.tools.react import ReactTool
20
35
  from tunacode.types import (
21
36
  AgentRun,
22
37
  ModelName,
23
38
  ToolCallback,
24
39
  UsageTrackerProtocol,
25
40
  )
41
+ from tunacode.ui import console as ui
26
42
  from tunacode.ui.tool_descriptions import get_batch_description
27
43
 
28
- # Import agent components
29
- from .agent_components import (
30
- AgentRunWithState,
31
- AgentRunWrapper,
32
- ResponseState,
33
- SimpleResult,
34
- ToolBuffer,
35
- _process_node,
36
- check_task_completion,
37
- create_empty_response_message,
38
- create_fallback_response,
39
- create_progress_summary,
40
- create_user_message,
41
- execute_tools_parallel,
42
- extract_and_execute_tool_calls,
43
- format_fallback_output,
44
- get_model_messages,
45
- get_or_create_agent,
46
- get_recent_tools_context,
47
- get_tool_summary,
48
- parse_json_tool_calls,
49
- patch_tool_messages,
50
- )
44
+ from . import agent_components as ac
45
+ from .prompts import format_clarification, format_iteration_limit, format_no_progress
51
46
 
52
- # Import streaming types with fallback for older versions
53
- try:
54
- from pydantic_ai.messages import PartDeltaEvent, TextPartDelta
55
-
56
- STREAMING_AVAILABLE = True
57
- except ImportError:
58
- PartDeltaEvent = None
59
- TextPartDelta = None
60
- STREAMING_AVAILABLE = False
61
-
62
- # Configure logging
63
47
  logger = get_logger(__name__)
64
48
 
65
49
  __all__ = [
66
- "ToolBuffer",
67
- "check_task_completion",
68
- "extract_and_execute_tool_calls",
69
- "get_model_messages",
70
- "parse_json_tool_calls",
71
- "patch_tool_messages",
72
- "get_mcp_servers",
73
- "check_query_satisfaction",
74
50
  "process_request",
75
- "get_or_create_agent",
76
- "_process_node",
77
- "ResponseState",
78
- "SimpleResult",
79
- "AgentRunWrapper",
80
- "AgentRunWithState",
81
- "execute_tools_parallel",
51
+ "get_mcp_servers",
52
+ "cleanup_mcp_servers",
53
+ "register_mcp_agent",
82
54
  "get_agent_tool",
55
+ "check_query_satisfaction",
83
56
  ]
84
57
 
85
58
 
86
- def get_agent_tool() -> tuple[type[Agent], type["Tool"]]:
87
- """Lazy import for Agent and Tool to avoid circular imports."""
88
- from pydantic_ai import Agent, Tool
89
-
90
- return Agent, Tool
91
-
92
-
93
- async def check_query_satisfaction(
94
- agent: Agent,
95
- original_query: str,
96
- response: str,
97
- state_manager: StateManager,
98
- ) -> bool:
99
- """Check if the response satisfies the original query."""
100
- return True # Agent uses TUNACODE_TASK_COMPLETE marker
101
-
102
-
103
- async def process_request(
104
- message: str,
105
- model: ModelName,
106
- state_manager: StateManager,
107
- tool_callback: Optional[ToolCallback] = None,
108
- streaming_callback: Optional[Callable[[str], Awaitable[None]]] = None,
109
- usage_tracker: Optional[UsageTrackerProtocol] = None,
110
- fallback_enabled: bool = True,
111
- ) -> AgentRun:
112
- """
113
- Process a single request to the agent.
114
-
115
- CLAUDE_ANCHOR[process-request-entry]: Main entry point for all agent requests
116
-
117
- Args:
118
- message: The user's request
119
- model: The model to use
120
- state_manager: State manager instance
121
- tool_callback: Optional callback for tool execution
122
- streaming_callback: Optional callback for streaming responses
123
- usage_tracker: Optional usage tracker
124
- fallback_enabled: Whether to enable fallback responses
125
-
126
- Returns:
127
- AgentRun or wrapper with result
128
- """
129
- # Get or create agent for the model
130
- agent = get_or_create_agent(model, state_manager)
131
-
132
- # Create a unique request ID for debugging
133
- import uuid
134
-
135
- request_id = str(uuid.uuid4())[:8]
136
-
137
- # Reset state for new request
138
- state_manager.session.current_iteration = 0
139
- state_manager.session.iteration_count = 0
140
- state_manager.session.tool_calls = []
59
+ @dataclass
60
+ class AgentConfig:
61
+ """Configuration for agent behavior."""
141
62
 
142
- # Initialize batch counter if not exists
143
- if not hasattr(state_manager.session, "batch_counter"):
144
- state_manager.session.batch_counter = 0
63
+ max_iterations: int = 15
64
+ unproductive_limit: int = 3
65
+ forced_react_interval: int = 2
66
+ forced_react_limit: int = 5
67
+ debug_metrics: bool = False
145
68
 
146
- # Create tool buffer for parallel execution
147
- tool_buffer = ToolBuffer()
148
69
 
149
- # Track iterations and productivity
150
- max_iterations = state_manager.session.user_config.get("settings", {}).get("max_iterations", 15)
151
- unproductive_iterations = 0
152
- last_productive_iteration = 0
70
+ @dataclass(slots=True)
71
+ class RequestContext:
72
+ """Context for a single request."""
153
73
 
154
- # Track response state
155
- response_state = ResponseState()
74
+ request_id: str
75
+ max_iterations: int
76
+ debug_metrics: bool
156
77
 
157
- try:
158
- # Get message history from session messages
159
- # Create a copy of the message history to avoid modifying the original
160
- message_history = list(state_manager.session.messages)
161
-
162
- async with agent.iter(message, message_history=message_history) as agent_run:
163
- # Process nodes iteratively
164
- i = 1
165
- async for node in agent_run:
166
- state_manager.session.current_iteration = i
167
- state_manager.session.iteration_count = i
168
-
169
- # Handle token-level streaming for model request nodes
170
- Agent, _ = get_agent_tool()
171
- if streaming_callback and STREAMING_AVAILABLE and Agent.is_model_request_node(node):
172
- async with node.stream(agent_run.ctx) as request_stream:
173
- async for event in request_stream:
174
- if isinstance(event, PartDeltaEvent) and isinstance(
175
- event.delta, TextPartDelta
176
- ):
177
- # Stream individual token deltas
178
- if event.delta.content_delta and streaming_callback:
179
- await streaming_callback(event.delta.content_delta)
180
-
181
- empty_response, empty_reason = await _process_node(
182
- node,
183
- tool_callback,
184
- state_manager,
185
- tool_buffer,
186
- streaming_callback,
187
- usage_tracker,
188
- response_state,
189
- )
190
78
 
191
- # Handle empty response
192
- if empty_response:
193
- if not hasattr(state_manager.session, "consecutive_empty_responses"):
194
- state_manager.session.consecutive_empty_responses = 0
195
- state_manager.session.consecutive_empty_responses += 1
196
-
197
- if state_manager.session.consecutive_empty_responses >= 1:
198
- force_action_content = create_empty_response_message(
199
- message,
200
- empty_reason,
201
- state_manager.session.tool_calls,
202
- i,
203
- state_manager,
204
- )
205
- create_user_message(force_action_content, state_manager)
79
+ class EmptyResponseHandler:
80
+ """Handles tracking and intervention for empty responses."""
206
81
 
207
- if state_manager.session.show_thoughts:
208
- from tunacode.ui import console as ui
82
+ def __init__(self, state_manager: StateManager) -> None:
83
+ self.state_manager = state_manager
209
84
 
210
- await ui.warning(
211
- "\n⚠️ EMPTY RESPONSE FAILURE - AGGRESSIVE RETRY TRIGGERED"
212
- )
213
- await ui.muted(f" Reason: {empty_reason}")
214
- await ui.muted(
215
- f" Recent tools: {get_recent_tools_context(state_manager.session.tool_calls)}"
216
- )
217
- await ui.muted(" Injecting 'YOU FAILED TRY HARDER' prompt")
85
+ def track(self, is_empty: bool) -> None:
86
+ """Track empty response and increment counter if empty."""
87
+ if is_empty:
88
+ current = getattr(self.state_manager.session, "consecutive_empty_responses", 0)
89
+ setattr(self.state_manager.session, "consecutive_empty_responses", current + 1)
90
+ else:
91
+ setattr(self.state_manager.session, "consecutive_empty_responses", 0)
218
92
 
219
- state_manager.session.consecutive_empty_responses = 0
220
- else:
221
- if hasattr(state_manager.session, "consecutive_empty_responses"):
222
- state_manager.session.consecutive_empty_responses = 0
93
+ def should_intervene(self) -> bool:
94
+ """Check if intervention is needed (>= 1 consecutive empty)."""
95
+ return getattr(self.state_manager.session, "consecutive_empty_responses", 0) >= 1
223
96
 
224
- if hasattr(node, "result") and node.result and hasattr(node.result, "output"):
225
- if node.result.output:
226
- response_state.has_user_response = True
97
+ async def prompt_action(self, message: str, reason: str, iteration: int) -> None:
98
+ """Delegate to agent_components.handle_empty_response."""
227
99
 
228
- # Track productivity - check if any tools were used in this iteration
229
- iteration_had_tools = False
230
- if hasattr(node, "model_response"):
231
- for part in node.model_response.parts:
232
- if hasattr(part, "part_kind") and part.part_kind == "tool-call":
233
- iteration_had_tools = True
234
- break
235
-
236
- if iteration_had_tools:
237
- # Reset unproductive counter
238
- unproductive_iterations = 0
239
- last_productive_iteration = i
240
- else:
241
- # Increment unproductive counter
242
- unproductive_iterations += 1
243
-
244
- # After 3 unproductive iterations, force action
245
- if unproductive_iterations >= 3 and not response_state.task_completed:
246
- no_progress_content = f"""ALERT: No tools executed for {unproductive_iterations} iterations.
247
-
248
- Last productive iteration: {last_productive_iteration}
249
- Current iteration: {i}/{max_iterations}
250
- Task: {message[:200]}...
100
+ # Create a minimal state-like object for compatibility
101
+ class StateProxy:
102
+ def __init__(self, sm: StateManager) -> None:
103
+ self.sm = sm
104
+ self.show_thoughts = bool(getattr(sm.session, "show_thoughts", False))
251
105
 
252
- You're describing actions but not executing them. You MUST:
106
+ state_proxy = StateProxy(self.state_manager)
107
+ await ac.handle_empty_response(message, reason, iteration, state_proxy)
108
+ # Clear after intervention
109
+ setattr(self.state_manager.session, "consecutive_empty_responses", 0)
253
110
 
254
- 1. If task is COMPLETE: Start response with TUNACODE_TASK_COMPLETE
255
- 2. If task needs work: Execute a tool RIGHT NOW (grep, read_file, bash, etc.)
256
- 3. If stuck: Explain the specific blocker
257
111
 
258
- NO MORE DESCRIPTIONS. Take ACTION or mark COMPLETE."""
112
+ class IterationManager:
113
+ """Manages iteration tracking, productivity monitoring, and limit handling."""
259
114
 
260
- create_user_message(no_progress_content, state_manager)
115
+ def __init__(self, state_manager: StateManager, config: AgentConfig) -> None:
116
+ self.state_manager = state_manager
117
+ self.config = config
118
+ self.unproductive_iterations = 0
119
+ self.last_productive_iteration = 0
261
120
 
262
- if state_manager.session.show_thoughts:
263
- from tunacode.ui import console as ui
121
+ def track_productivity(self, had_tool_use: bool, iteration: int) -> None:
122
+ """Track productivity based on tool usage."""
123
+ if had_tool_use:
124
+ self.unproductive_iterations = 0
125
+ self.last_productive_iteration = iteration
126
+ else:
127
+ self.unproductive_iterations += 1
264
128
 
265
- await ui.warning(
266
- f"⚠️ NO PROGRESS: {unproductive_iterations} iterations without tool usage"
267
- )
129
+ def should_force_action(self, response_state: ac.ResponseState) -> bool:
130
+ """Check if action should be forced due to unproductivity."""
131
+ return (
132
+ self.unproductive_iterations >= self.config.unproductive_limit
133
+ and not response_state.task_completed
134
+ )
268
135
 
269
- unproductive_iterations = 0
136
+ async def handle_iteration_limit(
137
+ self, iteration: int, response_state: ac.ResponseState
138
+ ) -> None:
139
+ """Handle reaching iteration limit."""
140
+ if iteration >= self.config.max_iterations and not response_state.task_completed:
141
+ _, tools_str = ac.create_progress_summary(
142
+ getattr(self.state_manager.session, "tool_calls", [])
143
+ )
144
+ limit_message = format_iteration_limit(self.config.max_iterations, iteration, tools_str)
145
+ ac.create_user_message(limit_message, self.state_manager)
146
+
147
+ if getattr(self.state_manager.session, "show_thoughts", False):
148
+ await ui.muted(
149
+ f"\nITERATION LIMIT: Awaiting user guidance at "
150
+ f"{self.config.max_iterations} iterations"
151
+ )
270
152
 
271
- # REMOVED: Recursive satisfaction check that caused empty responses
272
- # The agent now decides completion using TUNACODE_TASK_COMPLETE marker
273
- # This eliminates recursive agent calls and gives control back to the agent
153
+ response_state.awaiting_user_guidance = True
154
+
155
+ def update_counters(self, iteration: int) -> None:
156
+ """Update session iteration counters."""
157
+ self.state_manager.session.current_iteration = iteration
158
+ self.state_manager.session.iteration_count = iteration
159
+
160
+ async def force_action_if_unproductive(
161
+ self, message: str, iteration: int, response_state: ac.ResponseState
162
+ ) -> None:
163
+ """Force action if unproductive iterations exceeded."""
164
+ if not self.should_force_action(response_state):
165
+ return
166
+
167
+ no_progress_message = format_no_progress(
168
+ message,
169
+ self.unproductive_iterations,
170
+ self.last_productive_iteration,
171
+ iteration,
172
+ self.config.max_iterations,
173
+ )
174
+ ac.create_user_message(no_progress_message, self.state_manager)
274
175
 
275
- # Store original query for reference
276
- if not hasattr(state_manager.session, "original_query"):
277
- state_manager.session.original_query = message
176
+ if getattr(self.state_manager.session, "show_thoughts", False):
177
+ await ui.warning(
178
+ f"NO PROGRESS: {self.unproductive_iterations} iterations without tool usage"
179
+ )
278
180
 
279
- # Display iteration progress if thoughts are enabled
280
- if state_manager.session.show_thoughts:
281
- from tunacode.ui import console as ui
181
+ # Reset after nudge
182
+ self.unproductive_iterations = 0
282
183
 
283
- await ui.muted(f"\nITERATION: {i}/{max_iterations} (Request ID: {request_id})")
184
+ async def ask_for_clarification(self, iteration: int) -> None:
185
+ """Ask user for clarification."""
186
+ _, tools_used_str = ac.create_progress_summary(
187
+ getattr(self.state_manager.session, "tool_calls", [])
188
+ )
189
+ original_query = getattr(self.state_manager.session, "original_query", "your request")
190
+ clarification_message = format_clarification(original_query, iteration, tools_used_str)
191
+ ac.create_user_message(clarification_message, self.state_manager)
192
+
193
+ if getattr(self.state_manager.session, "show_thoughts", False):
194
+ await ui.muted("\nSEEKING CLARIFICATION: Asking user for guidance on task progress")
195
+
196
+
197
+ class ReactSnapshotManager:
198
+ """Manages forced react snapshots and guidance injection."""
199
+
200
+ def __init__(
201
+ self, state_manager: StateManager, react_tool: ReactTool, config: AgentConfig
202
+ ) -> None:
203
+ self.state_manager = state_manager
204
+ self.react_tool = react_tool
205
+ self.config = config
206
+
207
+ def should_snapshot(self, iteration: int) -> bool:
208
+ """Check if snapshot should be taken."""
209
+ if iteration < self.config.forced_react_interval:
210
+ return False
211
+ if iteration % self.config.forced_react_interval != 0:
212
+ return False
213
+
214
+ forced_calls = getattr(self.state_manager.session, "react_forced_calls", 0)
215
+ return forced_calls < self.config.forced_react_limit
216
+
217
+ async def capture_snapshot(self, iteration: int, agent_run_ctx: Any, show_debug: bool) -> None:
218
+ """Capture react snapshot and inject guidance."""
219
+ if not self.should_snapshot(iteration):
220
+ return
221
+
222
+ try:
223
+ await self.react_tool.execute(
224
+ action="think",
225
+ thoughts=f"Auto snapshot after iteration {iteration}",
226
+ next_action="continue",
227
+ )
228
+
229
+ # Increment forced calls counter
230
+ forced_calls = getattr(self.state_manager.session, "react_forced_calls", 0)
231
+ self.state_manager.session.react_forced_calls = forced_calls + 1
232
+
233
+ # Build guidance from last tool call
234
+ timeline = self.state_manager.session.react_scratchpad.get("timeline", [])
235
+ latest = timeline[-1] if timeline else {"thoughts": "?", "next_action": "?"}
236
+ summary = latest.get("thoughts", "")
237
+
238
+ tool_calls = getattr(self.state_manager.session, "tool_calls", [])
239
+ if tool_calls:
240
+ last_tool = tool_calls[-1]
241
+ tool_name = last_tool.get("tool", "tool")
242
+ args = last_tool.get("args", {})
243
+ if isinstance(args, str):
244
+ try:
245
+ args = json.loads(args)
246
+ except (ValueError, TypeError):
247
+ args = {}
248
+
249
+ detail = ""
250
+ if tool_name == "grep" and isinstance(args, dict):
251
+ pattern = args.get("pattern")
252
+ detail = (
253
+ f"Review grep results for pattern '{pattern}'"
254
+ if pattern
255
+ else "Review grep results"
256
+ )
257
+ elif tool_name == "read_file" and isinstance(args, dict):
258
+ path = args.get("file_path") or args.get("filepath")
259
+ detail = (
260
+ f"Extract key notes from {path}" if path else "Summarize read_file output"
261
+ )
262
+ else:
263
+ detail = f"Act on {tool_name} findings"
264
+ else:
265
+ detail = "Plan your first lookup"
266
+
267
+ guidance_entry = (
268
+ f"React snapshot {forced_calls + 1}/{self.config.forced_react_limit} "
269
+ f"at iteration {iteration}: {summary}. Next: {detail}"
270
+ )
271
+
272
+ # Append and trim guidance
273
+ self.state_manager.session.react_guidance.append(guidance_entry)
274
+ if len(self.state_manager.session.react_guidance) > self.config.forced_react_limit:
275
+ self.state_manager.session.react_guidance = (
276
+ self.state_manager.session.react_guidance[-self.config.forced_react_limit :]
277
+ )
284
278
 
285
- # Show summary of tools used so far
286
- if state_manager.session.tool_calls:
287
- tool_summary = get_tool_summary(state_manager.session.tool_calls)
288
- summary_str = ", ".join(
289
- [f"{name}: {count}" for name, count in tool_summary.items()]
290
- )
291
- await ui.muted(f"TOOLS USED: {summary_str}")
279
+ # CRITICAL: Inject into agent_run.ctx.messages so next LLM call sees guidance
280
+ if agent_run_ctx is not None:
281
+ ctx_messages = getattr(agent_run_ctx, "messages", None)
282
+ if isinstance(ctx_messages, list):
283
+ ModelRequest, _, SystemPromptPart = ac.get_model_messages()
284
+ system_part = SystemPromptPart(
285
+ content=f"[React Guidance] {guidance_entry}",
286
+ part_kind="system-prompt",
287
+ )
288
+ # CLAUDE_ANCHOR[react-system-injection]
289
+ # Append synthetic system message so LLM receives react guidance next turn
290
+ ctx_messages.append(ModelRequest(parts=[system_part], kind="request"))
291
+
292
+ if show_debug:
293
+ await ui.muted("\n[react → LLM] BEGIN\n" + guidance_entry + "\n[react → LLM] END\n")
294
+ except Exception:
295
+ logger.debug("Forced react snapshot failed", exc_info=True)
296
+
297
+
298
+ class RequestOrchestrator:
299
+ """Orchestrates the main request processing loop."""
300
+
301
+ def __init__(
302
+ self,
303
+ message: str,
304
+ model: ModelName,
305
+ state_manager: StateManager,
306
+ tool_callback: Optional[ToolCallback],
307
+ streaming_callback: Optional[Callable[[str], Awaitable[None]]],
308
+ usage_tracker: Optional[UsageTrackerProtocol],
309
+ ) -> None:
310
+ self.message = message
311
+ self.model = model
312
+ self.state_manager = state_manager
313
+ self.tool_callback = tool_callback
314
+ self.streaming_callback = streaming_callback
315
+ self.usage_tracker = usage_tracker
316
+
317
+ # Initialize config from session settings
318
+ user_config = getattr(state_manager.session, "user_config", {}) or {}
319
+ settings = user_config.get("settings", {})
320
+ self.config = AgentConfig(
321
+ max_iterations=int(settings.get("max_iterations", 15)),
322
+ unproductive_limit=3,
323
+ forced_react_interval=2,
324
+ forced_react_limit=5,
325
+ debug_metrics=bool(settings.get("debug_metrics", False)),
326
+ )
292
327
 
293
- # User clarification: Ask user for guidance when explicitly awaiting
294
- if response_state.awaiting_user_guidance:
295
- _, tools_used_str = create_progress_summary(state_manager.session.tool_calls)
328
+ # Initialize managers
329
+ self.empty_handler = EmptyResponseHandler(state_manager)
330
+ self.iteration_manager = IterationManager(state_manager, self.config)
331
+ self.react_manager = ReactSnapshotManager(
332
+ state_manager, ReactTool(state_manager=state_manager), self.config
333
+ )
296
334
 
297
- clarification_content = f"""I need clarification to continue.
335
+ def _init_request_context(self) -> RequestContext:
336
+ """Initialize request context with ID and config."""
337
+ req_id = str(uuid.uuid4())[:8]
338
+ setattr(self.state_manager.session, "request_id", req_id)
298
339
 
299
- Original request: {getattr(state_manager.session, "original_query", "your request")}
340
+ return RequestContext(
341
+ request_id=req_id,
342
+ max_iterations=self.config.max_iterations,
343
+ debug_metrics=self.config.debug_metrics,
344
+ )
300
345
 
301
- Progress so far:
302
- - Iterations: {i}
303
- - Tools used: {tools_used_str}
346
+ def _reset_session_state(self) -> None:
347
+ """Reset/initialize fields needed for a new run."""
348
+ self.state_manager.session.current_iteration = 0
349
+ self.state_manager.session.iteration_count = 0
350
+ self.state_manager.session.tool_calls = []
351
+ self.state_manager.session.react_forced_calls = 0
352
+ self.state_manager.session.react_guidance = []
353
+
354
+ # Counter used by other subsystems; initialize if absent
355
+ if not hasattr(self.state_manager.session, "batch_counter"):
356
+ self.state_manager.session.batch_counter = 0
357
+
358
+ # Track empty response streaks
359
+ setattr(self.state_manager.session, "consecutive_empty_responses", 0)
360
+
361
+ setattr(self.state_manager.session, "original_query", "")
362
+
363
+ def _set_original_query_once(self, query: str) -> None:
364
+ """Set original query if not already set."""
365
+ if not getattr(self.state_manager.session, "original_query", None):
366
+ setattr(self.state_manager.session, "original_query", query)
367
+
368
+ async def run(self) -> AgentRun:
369
+ """Run the main request processing loop."""
370
+ ctx = self._init_request_context()
371
+ self._reset_session_state()
372
+ self._set_original_query_once(self.message)
373
+
374
+ # Acquire agent
375
+ agent = ac.get_or_create_agent(self.model, self.state_manager)
376
+
377
+ # Prepare history snapshot
378
+ message_history = list(getattr(self.state_manager.session, "messages", []))
379
+
380
+ # Per-request trackers
381
+ tool_buffer = ac.ToolBuffer()
382
+ response_state = ac.ResponseState()
383
+
384
+ try:
385
+ async with agent.iter(self.message, message_history=message_history) as agent_run:
386
+ i = 1
387
+ async for node in agent_run:
388
+ self.iteration_manager.update_counters(i)
389
+
390
+ # Optional token streaming
391
+ await _maybe_stream_node_tokens(
392
+ node,
393
+ agent_run.ctx,
394
+ self.state_manager,
395
+ self.streaming_callback,
396
+ ctx.request_id,
397
+ i,
398
+ )
399
+
400
+ # Core node processing
401
+ empty_response, empty_reason = await ac._process_node( # noqa: SLF001
402
+ node,
403
+ self.tool_callback,
404
+ self.state_manager,
405
+ tool_buffer,
406
+ self.streaming_callback,
407
+ self.usage_tracker,
408
+ response_state,
409
+ )
410
+
411
+ # Handle empty response
412
+ self.empty_handler.track(empty_response)
413
+ if empty_response and self.empty_handler.should_intervene():
414
+ await self.empty_handler.prompt_action(self.message, empty_reason, i)
415
+
416
+ # Track whether we produced visible user output this iteration
417
+ if getattr(getattr(node, "result", None), "output", None):
418
+ response_state.has_user_response = True
304
419
 
305
- If the task is complete, I should respond with TUNACODE_TASK_COMPLETE.
306
- Otherwise, please provide specific guidance on what to do next."""
420
+ # Productivity tracking
421
+ had_tool_use = _iteration_had_tool_use(node)
422
+ self.iteration_manager.track_productivity(had_tool_use, i)
307
423
 
308
- create_user_message(clarification_content, state_manager)
424
+ # Force action if unproductive
425
+ await self.iteration_manager.force_action_if_unproductive(
426
+ self.message, i, response_state
427
+ )
309
428
 
310
- if state_manager.session.show_thoughts:
311
- from tunacode.ui import console as ui
429
+ # Force react snapshot
430
+ show_thoughts = bool(
431
+ getattr(self.state_manager.session, "show_thoughts", False)
432
+ )
433
+ await self.react_manager.capture_snapshot(i, agent_run.ctx, show_thoughts)
312
434
 
435
+ # Optional debug progress
436
+ if show_thoughts:
313
437
  await ui.muted(
314
- "\n🤔 SEEKING CLARIFICATION: Asking user for guidance on task progress"
438
+ f"\nITERATION: {i}/{ctx.max_iterations} (Request ID: {ctx.request_id})"
315
439
  )
440
+ tool_summary = ac.get_tool_summary(
441
+ getattr(self.state_manager.session, "tool_calls", [])
442
+ )
443
+ if tool_summary:
444
+ summary_str = ", ".join(
445
+ f"{name}: {count}" for name, count in tool_summary.items()
446
+ )
447
+ await ui.muted(f"TOOLS USED: {summary_str}")
448
+
449
+ # Ask for clarification if agent requested it
450
+ if response_state.awaiting_user_guidance:
451
+ await self.iteration_manager.ask_for_clarification(i)
452
+
453
+ # Early completion
454
+ if response_state.task_completed:
455
+ if show_thoughts:
456
+ await ui.success("Task completed successfully")
457
+ break
458
+
459
+ # Handle iteration limit
460
+ await self.iteration_manager.handle_iteration_limit(i, response_state)
461
+
462
+ i += 1
463
+
464
+ await _finalize_buffered_tasks(tool_buffer, self.tool_callback, self.state_manager)
465
+
466
+ # Return wrapper that carries response_state
467
+ return ac.AgentRunWithState(agent_run, response_state)
468
+
469
+ except UserAbortError:
470
+ raise
471
+ except ToolBatchingJSONError as e:
472
+ logger.error("Tool batching JSON error [req=%s]: %s", ctx.request_id, e, exc_info=True)
473
+ ac.patch_tool_messages(
474
+ f"Tool batching failed: {str(e)[:100]}...", state_manager=self.state_manager
475
+ )
476
+ raise
477
+ except Exception as e:
478
+ # Attach request/iteration context for observability
479
+ safe_iter = getattr(self.state_manager.session, "current_iteration", "?")
480
+ logger.error(
481
+ "Error in process_request [req=%s iter=%s]: %s",
482
+ ctx.request_id,
483
+ safe_iter,
484
+ e,
485
+ exc_info=True,
486
+ )
487
+ ac.patch_tool_messages(
488
+ f"Request processing failed: {str(e)[:100]}...",
489
+ state_manager=self.state_manager,
490
+ )
491
+ raise
492
+
493
+
494
+ # Utility functions
495
+
496
+
497
+ async def _maybe_stream_node_tokens(
498
+ node: Any,
499
+ agent_run_ctx: Any,
500
+ state_manager: StateManager,
501
+ streaming_cb: Optional[Callable[[str], Awaitable[None]]],
502
+ request_id: str,
503
+ iteration_index: int,
504
+ ) -> None:
505
+ """Stream tokens from model request nodes if callback provided.
316
506
 
317
- response_state.awaiting_user_guidance = True
507
+ Reference: main.py lines 146-161
508
+ """
509
+ if not streaming_cb:
510
+ return
318
511
 
319
- # Check if task is explicitly completed
320
- if response_state.task_completed:
321
- if state_manager.session.show_thoughts:
322
- from tunacode.ui import console as ui
512
+ # Delegate to component streaming helper
513
+ if Agent.is_model_request_node(node): # type: ignore[attr-defined]
514
+ await ac.stream_model_request_node(
515
+ node, agent_run_ctx, state_manager, streaming_cb, request_id, iteration_index
516
+ )
323
517
 
324
- await ui.success("Task completed successfully")
325
- break
326
518
 
327
- if i >= max_iterations and not response_state.task_completed:
328
- _, tools_str = create_progress_summary(state_manager.session.tool_calls)
329
- tools_str = tools_str if tools_str != "No tools used yet" else "No tools used"
519
+ def _iteration_had_tool_use(node: Any) -> bool:
520
+ """Inspect the node to see if model responded with any tool-call parts.
330
521
 
331
- extend_content = f"""I've reached the iteration limit ({max_iterations}).
522
+ Reference: main.py lines 164-171
523
+ """
524
+ if hasattr(node, "model_response"):
525
+ for part in getattr(node.model_response, "parts", []):
526
+ # pydantic-ai annotates tool calls; be resilient to attr differences
527
+ if getattr(part, "part_kind", None) == "tool-call":
528
+ return True
529
+ return False
332
530
 
333
- Progress summary:
334
- - Tools used: {tools_str}
335
- - Iterations completed: {i}
336
531
 
337
- The task appears incomplete. Would you like me to:
338
- 1. Continue working (I can extend the limit)
339
- 2. Summarize what I've done and stop
340
- 3. Try a different approach
532
+ async def _finalize_buffered_tasks(
533
+ tool_buffer: ac.ToolBuffer,
534
+ tool_callback: Optional[ToolCallback],
535
+ state_manager: StateManager,
536
+ ) -> None:
537
+ """Finalize and execute any buffered read-only tasks."""
538
+ if not tool_callback or not tool_buffer.has_tasks():
539
+ return
341
540
 
342
- Please let me know how to proceed."""
541
+ buffered_tasks = tool_buffer.flush()
343
542
 
344
- create_user_message(extend_content, state_manager)
543
+ # Update spinner message (best-effort)
544
+ try:
545
+ tool_names = [part.tool_name for part, _ in buffered_tasks]
546
+ batch_msg = get_batch_description(len(buffered_tasks), tool_names)
547
+ await ui.update_spinner_message(
548
+ f"[bold #00d7ff]{batch_msg}...[/bold #00d7ff]", state_manager
549
+ )
550
+ except Exception:
551
+ # UI is best-effort; never fail request because of display
552
+ logger.debug("UI batch prelude failed (non-fatal)", exc_info=True)
345
553
 
346
- if state_manager.session.show_thoughts:
347
- from tunacode.ui import console as ui
554
+ # Execute
555
+ await ac.execute_tools_parallel(buffered_tasks, tool_callback)
348
556
 
349
- await ui.muted(
350
- f"\n📊 ITERATION LIMIT: Asking user for guidance at {max_iterations} iterations"
351
- )
557
+ # Reset spinner message (best-effort)
558
+ try:
559
+ from tunacode.constants import UI_THINKING_MESSAGE # local import OK (rare path)
352
560
 
353
- max_iterations += 5
354
- response_state.awaiting_user_guidance = True
561
+ await ui.update_spinner_message(UI_THINKING_MESSAGE, state_manager)
562
+ except Exception:
563
+ logger.debug("UI batch epilogue failed (non-fatal)", exc_info=True)
355
564
 
356
- # Increment iteration counter
357
- i += 1
358
565
 
359
- # Final flush: execute any remaining buffered read-only tools
360
- if tool_callback and tool_buffer.has_tasks():
361
- import time
566
+ def get_agent_tool() -> tuple[type[Agent], type["Tool"]]:
567
+ """Return Agent and Tool classes without importing at module load time.
362
568
 
363
- from tunacode.ui import console as ui
569
+ Reference: main.py lines 354-359
570
+ """
571
+ from pydantic_ai import Agent as AgentCls
572
+ from pydantic_ai import Tool as ToolCls
364
573
 
365
- buffered_tasks = tool_buffer.flush()
366
- start_time = time.time()
574
+ return AgentCls, ToolCls
367
575
 
368
- # Update spinner message for final batch execution
369
- tool_names = [part.tool_name for part, _ in buffered_tasks]
370
- batch_msg = get_batch_description(len(buffered_tasks), tool_names)
371
- await ui.update_spinner_message(
372
- f"[bold #00d7ff]{batch_msg}...[/bold #00d7ff]", state_manager
373
- )
374
576
 
375
- await ui.muted("\n" + "=" * 60)
376
- await ui.muted(
377
- f"🚀 FINAL BATCH: Executing {len(buffered_tasks)} buffered read-only tools"
378
- )
379
- await ui.muted("=" * 60)
380
-
381
- for idx, (part, node) in enumerate(buffered_tasks, 1):
382
- tool_desc = f" [{idx}] {part.tool_name}"
383
- if hasattr(part, "args") and isinstance(part.args, dict):
384
- if part.tool_name == "read_file" and "file_path" in part.args:
385
- tool_desc += f" → {part.args['file_path']}"
386
- elif part.tool_name == "grep" and "pattern" in part.args:
387
- tool_desc += f" → pattern: '{part.args['pattern']}'"
388
- if "include_files" in part.args:
389
- tool_desc += f", files: '{part.args['include_files']}'"
390
- elif part.tool_name == "list_dir" and "directory" in part.args:
391
- tool_desc += f" → {part.args['directory']}"
392
- elif part.tool_name == "glob" and "pattern" in part.args:
393
- tool_desc += f" → pattern: '{part.args['pattern']}'"
394
- await ui.muted(tool_desc)
395
- await ui.muted("=" * 60)
396
-
397
- await execute_tools_parallel(buffered_tasks, tool_callback)
398
-
399
- elapsed_time = (time.time() - start_time) * 1000
400
- sequential_estimate = len(buffered_tasks) * 100
401
- speedup = sequential_estimate / elapsed_time if elapsed_time > 0 else 1.0
577
+ async def check_query_satisfaction(
578
+ agent: Agent,
579
+ original_query: str,
580
+ response: str,
581
+ state_manager: StateManager,
582
+ ) -> bool:
583
+ """Legacy hook for compatibility; completion still signaled via DONE marker."""
584
+ return True
402
585
 
403
- await ui.muted(
404
- f"✅ Final batch completed in {elapsed_time:.0f}ms "
405
- f"(~{speedup:.1f}x faster than sequential)\n"
406
- )
407
586
 
408
- # Reset spinner back to thinking
409
- from tunacode.constants import UI_THINKING_MESSAGE
587
+ # Main entry point
410
588
 
411
- await ui.update_spinner_message(UI_THINKING_MESSAGE, state_manager)
412
589
 
413
- # If we need to add a fallback response, create a wrapper
414
- if (
415
- not response_state.has_user_response
416
- and not response_state.task_completed
417
- and i >= max_iterations
418
- and fallback_enabled
419
- ):
420
- patch_tool_messages("Task incomplete", state_manager=state_manager)
421
- response_state.has_final_synthesis = True
590
+ async def process_request(
591
+ message: str,
592
+ model: ModelName,
593
+ state_manager: StateManager,
594
+ tool_callback: Optional[ToolCallback] = None,
595
+ streaming_callback: Optional[Callable[[str], Awaitable[None]]] = None,
596
+ usage_tracker: Optional[UsageTrackerProtocol] = None,
597
+ ) -> AgentRun:
598
+ """
599
+ Process a single request to the agent.
422
600
 
423
- verbosity = state_manager.session.user_config.get("settings", {}).get(
424
- "fallback_verbosity", "normal"
425
- )
426
- fallback = create_fallback_response(
427
- i,
428
- max_iterations,
429
- state_manager.session.tool_calls,
430
- state_manager.session.messages,
431
- verbosity,
432
- )
433
- comprehensive_output = format_fallback_output(fallback)
601
+ CLAUDE_ANCHOR[process-request-entry]: Main entry point for all agent requests
434
602
 
435
- wrapper = AgentRunWrapper(
436
- agent_run, SimpleResult(comprehensive_output), response_state
437
- )
438
- return wrapper
439
-
440
- # For non-fallback cases, we still need to handle the response_state
441
- # Create a minimal wrapper just to add response_state
442
- state_wrapper = AgentRunWithState(agent_run, response_state)
443
- return state_wrapper
444
-
445
- except UserAbortError:
446
- raise
447
- except ToolBatchingJSONError as e:
448
- logger.error(f"Tool batching JSON error: {e}", exc_info=True)
449
- # Patch orphaned tool messages with error
450
- patch_tool_messages(f"Tool batching failed: {str(e)[:100]}...", state_manager=state_manager)
451
- # Re-raise to be handled by caller
452
- raise
453
- except Exception as e:
454
- logger.error(f"Error in process_request: {e}", exc_info=True)
455
- # Patch orphaned tool messages with generic error
456
- patch_tool_messages(
457
- f"Request processing failed: {str(e)[:100]}...", state_manager=state_manager
458
- )
459
- # Re-raise to be handled by caller
460
- raise
603
+ """
604
+ orchestrator = RequestOrchestrator(
605
+ message,
606
+ model,
607
+ state_manager,
608
+ tool_callback,
609
+ streaming_callback,
610
+ usage_tracker,
611
+ )
612
+ return await orchestrator.run()