tunacode-cli 0.0.76__py3-none-any.whl → 0.0.76.2__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.

@@ -6,17 +6,25 @@ Handles agent creation, configuration, and request processing.
6
6
  CLAUDE_ANCHOR[main-agent-module]: Primary agent orchestration and lifecycle management
7
7
  """
8
8
 
9
- from typing import TYPE_CHECKING, Awaitable, Callable, Optional
9
+ from __future__ import annotations
10
+
11
+ import time
12
+ import uuid
13
+ from dataclasses import dataclass
14
+ from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, Optional
10
15
 
11
16
  from pydantic_ai import Agent
12
17
 
13
18
  if TYPE_CHECKING:
14
19
  from pydantic_ai import Tool # noqa: F401
15
20
 
21
+ from tunacode.core.agents.agent_components import ResponseState, ToolBuffer
22
+
16
23
  from tunacode.core.logging.logger import get_logger
17
24
  from tunacode.core.state import StateManager
18
25
  from tunacode.exceptions import ToolBatchingJSONError, UserAbortError
19
- from tunacode.services.mcp import get_mcp_servers
26
+ from tunacode.services.mcp import get_mcp_servers # re-exported by design
27
+ from tunacode.tools.react import ReactTool
20
28
  from tunacode.types import (
21
29
  AgentRun,
22
30
  ModelName,
@@ -25,69 +33,427 @@ from tunacode.types import (
25
33
  )
26
34
  from tunacode.ui.tool_descriptions import get_batch_description
27
35
 
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
- )
36
+ # Optional UI console (avoid nested imports in hot paths)
37
+ try:
38
+ from tunacode.ui import console as ui # rich-style helpers with async methods
39
+ except Exception: # pragma: no cover - UI is optional
51
40
 
52
- # Import streaming types with fallback for older versions
41
+ class _NoopUI: # minimal no-op shim
42
+ async def muted(self, *_: Any, **__: Any) -> None: ...
43
+ async def warning(self, *_: Any, **__: Any) -> None: ...
44
+ async def success(self, *_: Any, **__: Any) -> None: ...
45
+ async def update_spinner_message(self, *_: Any, **__: Any) -> None: ...
46
+
47
+ ui = _NoopUI() # type: ignore
48
+
49
+ # Streaming parts (keep guarded import but avoid per-iteration imports)
53
50
  try:
54
- from pydantic_ai.messages import PartDeltaEvent, TextPartDelta
51
+ from pydantic_ai.messages import PartDeltaEvent, TextPartDelta # type: ignore
55
52
 
56
53
  STREAMING_AVAILABLE = True
57
- except ImportError:
58
- PartDeltaEvent = None
59
- TextPartDelta = None
54
+ except Exception: # pragma: no cover
55
+ PartDeltaEvent = None # type: ignore
56
+ TextPartDelta = None # type: ignore
60
57
  STREAMING_AVAILABLE = False
61
58
 
59
+ # Agent components (flattned to a single module import to reduce coupling)
60
+ from . import agent_components as ac
61
+
62
62
  # Configure logging
63
63
  logger = get_logger(__name__)
64
64
 
65
+
66
+ # -----------------------
67
+ # Module exports
68
+ # -----------------------
65
69
  __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
70
  "process_request",
75
- "get_or_create_agent",
76
- "_process_node",
77
- "ResponseState",
78
- "SimpleResult",
79
- "AgentRunWrapper",
80
- "AgentRunWithState",
81
- "execute_tools_parallel",
71
+ "get_mcp_servers",
82
72
  "get_agent_tool",
73
+ "check_query_satisfaction",
83
74
  ]
84
75
 
76
+ # -----------------------
77
+ # Constants & Defaults
78
+ # -----------------------
79
+ DEFAULT_MAX_ITERATIONS = 15
80
+ UNPRODUCTIVE_LIMIT = 3 # iterations without tool use before forcing action
81
+ FALLBACK_VERBOSITY_DEFAULT = "normal"
82
+ DEBUG_METRICS_DEFAULT = False
83
+ FORCED_REACT_INTERVAL = 2
84
+ FORCED_REACT_LIMIT = 5
85
+
86
+
87
+ # -----------------------
88
+ # Data structures
89
+ # -----------------------
90
+ @dataclass(slots=True)
91
+ class RequestContext:
92
+ request_id: str
93
+ max_iterations: int
94
+ debug_metrics: bool
95
+ fallback_enabled: bool
96
+
97
+
98
+ class StateFacade:
99
+ """Thin wrapper to centralize session mutations and reads."""
100
+
101
+ def __init__(self, state_manager: StateManager) -> None:
102
+ self.sm = state_manager
103
+
104
+ # ---- safe getters ----
105
+ def get_setting(self, dotted: str, default: Any) -> Any:
106
+ cfg: Dict[str, Any] = getattr(self.sm.session, "user_config", {}) or {}
107
+ node = cfg
108
+ for key in dotted.split("."):
109
+ if not isinstance(node, dict) or key not in node:
110
+ return default
111
+ node = node[key]
112
+ return node
113
+
114
+ @property
115
+ def show_thoughts(self) -> bool:
116
+ return bool(getattr(self.sm.session, "show_thoughts", False))
117
+
118
+ @property
119
+ def messages(self) -> list:
120
+ return list(getattr(self.sm.session, "messages", []))
121
+
122
+ # ---- safe setters ----
123
+ def set_request_id(self, req_id: str) -> None:
124
+ try:
125
+ self.sm.session.request_id = req_id
126
+ except AttributeError:
127
+ logger.warning("Session missing 'request_id' attribute; unable to set (req=%s)", req_id)
128
+
129
+ def reset_for_new_request(self) -> None:
130
+ """Reset/initialize fields needed for a new run."""
131
+ # Keep all assignments here to avoid scattered mutations across the codebase.
132
+ setattr(self.sm.session, "current_iteration", 0)
133
+ setattr(self.sm.session, "iteration_count", 0)
134
+ setattr(self.sm.session, "tool_calls", [])
135
+ setattr(self.sm.session, "react_forced_calls", 0)
136
+ setattr(self.sm.session, "react_guidance", [])
137
+ # Counter used by other subsystems; initialize if absent
138
+ if not hasattr(self.sm.session, "batch_counter"):
139
+ setattr(self.sm.session, "batch_counter", 0)
140
+ # Track empty response streaks
141
+ setattr(self.sm.session, "consecutive_empty_responses", 0)
142
+ # Always reset original query so subsequent requests don't leak prompts
143
+ setattr(self.sm.session, "original_query", "")
144
+
145
+ def set_original_query_once(self, q: str) -> None:
146
+ if not getattr(self.sm.session, "original_query", None):
147
+ setattr(self.sm.session, "original_query", q)
148
+
149
+ # ---- progress helpers ----
150
+ def set_iteration(self, i: int) -> None:
151
+ setattr(self.sm.session, "current_iteration", i)
152
+ setattr(self.sm.session, "iteration_count", i)
153
+
154
+ def increment_empty_response(self) -> int:
155
+ v = int(getattr(self.sm.session, "consecutive_empty_responses", 0)) + 1
156
+ setattr(self.sm.session, "consecutive_empty_responses", v)
157
+ return v
158
+
159
+ def clear_empty_response(self) -> None:
160
+ setattr(self.sm.session, "consecutive_empty_responses", 0)
161
+
162
+
163
+ # -----------------------
164
+ # Helper functions
165
+ # -----------------------
166
+ def _init_context(state: StateFacade, fallback_enabled: bool) -> RequestContext:
167
+ req_id = str(uuid.uuid4())[:8]
168
+ state.set_request_id(req_id)
169
+
170
+ max_iters = int(state.get_setting("settings.max_iterations", DEFAULT_MAX_ITERATIONS))
171
+ debug_metrics = bool(state.get_setting("settings.debug_metrics", DEBUG_METRICS_DEFAULT))
172
+
173
+ return RequestContext(
174
+ request_id=req_id,
175
+ max_iterations=max_iters,
176
+ debug_metrics=debug_metrics,
177
+ fallback_enabled=fallback_enabled,
178
+ )
179
+
180
+
181
+ def _prepare_message_history(state: StateFacade) -> list:
182
+ return state.messages
183
+
184
+
185
+ async def _maybe_stream_node_tokens(
186
+ node: Any,
187
+ agent_run_ctx: Any,
188
+ state_manager: StateManager,
189
+ streaming_cb: Optional[Callable[[str], Awaitable[None]]],
190
+ request_id: str,
191
+ iteration_index: int,
192
+ ) -> None:
193
+ if not streaming_cb or not STREAMING_AVAILABLE:
194
+ return
195
+
196
+ # Delegate to component streaming helper (already optimized)
197
+ if Agent.is_model_request_node(node): # type: ignore[attr-defined]
198
+ await ac.stream_model_request_node(
199
+ node, agent_run_ctx, state_manager, streaming_cb, request_id, iteration_index
200
+ )
201
+
202
+
203
+ def _iteration_had_tool_use(node: Any) -> bool:
204
+ """Inspect the node to see if model responded with any tool-call parts."""
205
+ if hasattr(node, "model_response"):
206
+ for part in getattr(node.model_response, "parts", []):
207
+ # pydantic-ai annotates tool calls; be resilient to attr differences
208
+ if getattr(part, "part_kind", None) == "tool-call":
209
+ return True
210
+ return False
211
+
212
+
213
+ async def _maybe_force_react_snapshot(
214
+ iteration: int,
215
+ state_manager: StateManager,
216
+ react_tool: ReactTool,
217
+ show_debug: bool,
218
+ agent_run_ctx: Any | None = None,
219
+ ) -> None:
220
+ """CLAUDE_ANCHOR[react-forced-call]: Auto-log reasoning every two turns."""
221
+
222
+ if iteration < FORCED_REACT_INTERVAL or iteration % FORCED_REACT_INTERVAL != 0:
223
+ return
224
+
225
+ forced_calls = getattr(state_manager.session, "react_forced_calls", 0)
226
+ if forced_calls >= FORCED_REACT_LIMIT:
227
+ return
228
+
229
+ try:
230
+ await react_tool.execute(
231
+ action="think",
232
+ thoughts=f"Auto snapshot after iteration {iteration}",
233
+ next_action="continue",
234
+ )
235
+ state_manager.session.react_forced_calls = forced_calls + 1
236
+ timeline = state_manager.session.react_scratchpad.get("timeline", [])
237
+ latest = timeline[-1] if timeline else {"thoughts": "?", "next_action": "?"}
238
+ summary = latest.get("thoughts", "")
239
+ tool_calls = getattr(state_manager.session, "tool_calls", [])
240
+ if tool_calls:
241
+ last_tool = tool_calls[-1]
242
+ tool_name = last_tool.get("tool", "tool")
243
+ args = last_tool.get("args", {})
244
+ if isinstance(args, str):
245
+ try:
246
+ import json
247
+
248
+ args = json.loads(args)
249
+ except (ValueError, TypeError):
250
+ args = {}
251
+ detail = ""
252
+ if tool_name == "grep" and isinstance(args, dict):
253
+ pattern = args.get("pattern")
254
+ detail = (
255
+ f"Review grep results for pattern '{pattern}'"
256
+ if pattern
257
+ else "Review grep results"
258
+ )
259
+ elif tool_name == "read_file" and isinstance(args, dict):
260
+ path = args.get("filepath") or args.get("file_path")
261
+ detail = f"Extract key notes from {path}" if path else "Summarize read_file output"
262
+ else:
263
+ detail = f"Act on {tool_name} findings"
264
+ else:
265
+ detail = "Plan your first lookup"
266
+ guidance_entry = (
267
+ f"React snapshot {forced_calls + 1}/{FORCED_REACT_LIMIT} at iteration {iteration}:"
268
+ f" {summary}. Next: {detail}"
269
+ )
270
+ state_manager.session.react_guidance.append(guidance_entry)
271
+ if len(state_manager.session.react_guidance) > FORCED_REACT_LIMIT:
272
+ state_manager.session.react_guidance = state_manager.session.react_guidance[
273
+ -FORCED_REACT_LIMIT:
274
+ ]
275
+
276
+ if agent_run_ctx is not None:
277
+ ctx_messages = getattr(agent_run_ctx, "messages", None)
278
+ if isinstance(ctx_messages, list):
279
+ ModelRequest, _, SystemPromptPart = ac.get_model_messages()
280
+ system_part = SystemPromptPart(
281
+ content=f"[React Guidance] {guidance_entry}",
282
+ part_kind="system-prompt",
283
+ )
284
+ # CLAUDE_ANCHOR[react-system-injection]
285
+ # Append synthetic system message so LLM receives react guidance next turn
286
+ # This mutates the active run context so the very next model prompt includes the guidance
287
+ ctx_messages.append(ModelRequest(parts=[system_part], kind="request"))
288
+
289
+ if show_debug:
290
+ await ui.muted("\n[react → LLM] BEGIN\n" + guidance_entry + "\n[react → LLM] END\n")
291
+ except Exception:
292
+ logger.debug("Forced react snapshot failed", exc_info=True)
293
+
294
+
295
+ async def _handle_empty_response(
296
+ message: str,
297
+ reason: str,
298
+ iter_index: int,
299
+ state: StateFacade,
300
+ ) -> None:
301
+ force_action_content = ac.create_empty_response_message(
302
+ message,
303
+ reason,
304
+ getattr(state.sm.session, "tool_calls", []),
305
+ iter_index,
306
+ state.sm,
307
+ )
308
+ ac.create_user_message(force_action_content, state.sm)
309
+
310
+ if state.show_thoughts:
311
+ await ui.warning("\nEMPTY RESPONSE FAILURE - AGGRESSIVE RETRY TRIGGERED")
312
+ await ui.muted(f" Reason: {reason}")
313
+ await ui.muted(
314
+ f" Recent tools: {ac.get_recent_tools_context(getattr(state.sm.session, 'tool_calls', []))}"
315
+ )
316
+ await ui.muted(" Injecting retry guidance prompt")
317
+
318
+
319
+ async def _force_action_if_unproductive(
320
+ message: str,
321
+ unproductive_count: int,
322
+ last_productive: int,
323
+ i: int,
324
+ max_iterations: int,
325
+ state: StateFacade,
326
+ ) -> None:
327
+ no_progress_content = (
328
+ f"ALERT: No tools executed for {unproductive_count} iterations.\n\n"
329
+ f"Last productive iteration: {last_productive}\n"
330
+ f"Current iteration: {i}/{max_iterations}\n"
331
+ f"Task: {message[:200]}...\n\n"
332
+ "You're describing actions but not executing them. You MUST:\n\n"
333
+ "1. If task is COMPLETE: Start response with TUNACODE DONE:\n"
334
+ "2. If task needs work: Execute a tool RIGHT NOW (grep, read_file, bash, etc.)\n"
335
+ "3. If stuck: Explain the specific blocker\n\n"
336
+ "NO MORE DESCRIPTIONS. Take ACTION or mark COMPLETE."
337
+ )
338
+ ac.create_user_message(no_progress_content, state.sm)
339
+ if state.show_thoughts:
340
+ await ui.warning(f"NO PROGRESS: {unproductive_count} iterations without tool usage")
341
+
342
+
343
+ async def _ask_for_clarification(i: int, state: StateFacade) -> None:
344
+ _, tools_used_str = ac.create_progress_summary(getattr(state.sm.session, "tool_calls", []))
345
+
346
+ clarification_content = (
347
+ "I need clarification to continue.\n\n"
348
+ f"Original request: {getattr(state.sm.session, 'original_query', 'your request')}\n\n"
349
+ "Progress so far:\n"
350
+ f"- Iterations: {i}\n"
351
+ f"- Tools used: {tools_used_str}\n\n"
352
+ "If the task is complete, I should respond with TUNACODE DONE:\n"
353
+ "Otherwise, please provide specific guidance on what to do next."
354
+ )
355
+
356
+ ac.create_user_message(clarification_content, state.sm)
357
+ if state.show_thoughts:
358
+ await ui.muted("\nSEEKING CLARIFICATION: Asking user for guidance on task progress")
359
+
360
+
361
+ async def _finalize_buffered_tasks(
362
+ tool_buffer: ToolBuffer,
363
+ tool_callback: Optional[ToolCallback],
364
+ state: StateFacade,
365
+ ) -> None:
366
+ if not tool_callback or not tool_buffer.has_tasks():
367
+ return
368
+
369
+ buffered_tasks = tool_buffer.flush()
370
+
371
+ # Cosmetic UI around batch (kept but isolated here)
372
+ try:
373
+ tool_names = [part.tool_name for part, _ in buffered_tasks]
374
+ batch_msg = get_batch_description(len(buffered_tasks), tool_names)
375
+ await ui.update_spinner_message(f"[bold #00d7ff]{batch_msg}...[/bold #00d7ff]", state.sm)
376
+ await ui.muted("\n" + "=" * 60)
377
+ await ui.muted(f"FINAL BATCH: Executing {len(buffered_tasks)} buffered read-only tools")
378
+ await ui.muted("=" * 60)
379
+ for idx, (part, _node) in enumerate(buffered_tasks, 1):
380
+ tool_desc = f" [{idx}] {getattr(part, 'tool_name', 'tool')}"
381
+ args = getattr(part, "args", {})
382
+ if isinstance(args, dict):
383
+ if part.tool_name == "read_file" and "file_path" in args:
384
+ tool_desc += f" → {args['file_path']}"
385
+ elif part.tool_name == "grep" and "pattern" in args:
386
+ tool_desc += f" → pattern: '{args['pattern']}'"
387
+ if "include_files" in args:
388
+ tool_desc += f", files: '{args['include_files']}'"
389
+ elif part.tool_name == "list_dir" and "directory" in args:
390
+ tool_desc += f" → {args['directory']}"
391
+ elif part.tool_name == "glob" and "pattern" in args:
392
+ tool_desc += f" → pattern: '{args['pattern']}'"
393
+ await ui.muted(tool_desc)
394
+ await ui.muted("=" * 60)
395
+ except Exception:
396
+ # UI is best-effort; never fail request because of display
397
+ logger.debug("UI batch prelude failed (non-fatal)", exc_info=True)
398
+
399
+ # Execute
400
+ start = time.time()
401
+ await ac.execute_tools_parallel(buffered_tasks, tool_callback)
402
+ elapsed_ms = (time.time() - start) * 1000
403
+
404
+ # Post metrics (best-effort)
405
+ try:
406
+ sequential_estimate = len(buffered_tasks) * 100.0
407
+ speedup = (sequential_estimate / elapsed_ms) if elapsed_ms > 0 else 1.0
408
+ await ui.muted(
409
+ f"Final batch completed in {elapsed_ms:.0f}ms (~{speedup:.1f}x faster than sequential)\n"
410
+ )
411
+ from tunacode.constants import UI_THINKING_MESSAGE # local import OK (rare path)
412
+
413
+ await ui.update_spinner_message(UI_THINKING_MESSAGE, state.sm)
414
+ except Exception:
415
+ logger.debug("UI batch epilogue failed (non-fatal)", exc_info=True)
85
416
 
417
+
418
+ def _should_build_fallback(
419
+ response_state: ResponseState,
420
+ iter_idx: int,
421
+ max_iterations: int,
422
+ fallback_enabled: bool,
423
+ ) -> bool:
424
+ return (
425
+ fallback_enabled
426
+ and not response_state.has_user_response
427
+ and not response_state.task_completed
428
+ and iter_idx >= max_iterations
429
+ )
430
+
431
+
432
+ def _build_fallback_output(
433
+ iter_idx: int,
434
+ max_iterations: int,
435
+ state: StateFacade,
436
+ ) -> str:
437
+ verbosity = state.get_setting("settings.fallback_verbosity", FALLBACK_VERBOSITY_DEFAULT)
438
+ fallback = ac.create_fallback_response(
439
+ iter_idx,
440
+ max_iterations,
441
+ getattr(state.sm.session, "tool_calls", []),
442
+ getattr(state.sm.session, "messages", []),
443
+ verbosity,
444
+ )
445
+ return ac.format_fallback_output(fallback)
446
+
447
+
448
+ # -----------------------
449
+ # Public API
450
+ # -----------------------
86
451
  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
452
+ """Return Agent and Tool classes without importing at module load time."""
453
+ from pydantic_ai import Agent as AgentCls
454
+ from pydantic_ai import Tool as ToolCls
89
455
 
90
- return Agent, Tool
456
+ return AgentCls, ToolCls
91
457
 
92
458
 
93
459
  async def check_query_satisfaction(
@@ -96,8 +462,8 @@ async def check_query_satisfaction(
96
462
  response: str,
97
463
  state_manager: StateManager,
98
464
  ) -> bool:
99
- """Check if the response satisfies the original query."""
100
- return True # Completion decided via DONE marker in RESPONSE
465
+ """Legacy hook for compatibility; completion still signaled via DONE marker."""
466
+ return True
101
467
 
102
468
 
103
469
  async def process_request(
@@ -106,113 +472,48 @@ async def process_request(
106
472
  state_manager: StateManager,
107
473
  tool_callback: Optional[ToolCallback] = None,
108
474
  streaming_callback: Optional[Callable[[str], Awaitable[None]]] = None,
109
- usage_tracker: Optional[UsageTrackerProtocol] = None,
475
+ usage_tracker: Optional[
476
+ UsageTrackerProtocol
477
+ ] = None, # currently passed through to _process_node
110
478
  fallback_enabled: bool = True,
111
479
  ) -> AgentRun:
112
480
  """
113
481
  Process a single request to the agent.
114
482
 
115
483
  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
484
  """
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
- # Attach request_id to session for downstream logging/context
137
- try:
138
- state_manager.session.request_id = request_id
139
- except Exception:
140
- pass
485
+ state = StateFacade(state_manager)
486
+ fallback_config_enabled = bool(state.get_setting("settings.fallback_response", True))
487
+ ctx = _init_context(state, fallback_enabled=fallback_enabled and fallback_config_enabled)
488
+ state.reset_for_new_request()
489
+ state.set_original_query_once(message)
141
490
 
142
- # Reset state for new request
143
- state_manager.session.current_iteration = 0
144
- state_manager.session.iteration_count = 0
145
- state_manager.session.tool_calls = []
491
+ # Acquire agent (no local caching here; rely on upstream policies)
492
+ agent = ac.get_or_create_agent(model, state_manager)
146
493
 
147
- # Initialize batch counter if not exists
148
- if not hasattr(state_manager.session, "batch_counter"):
149
- state_manager.session.batch_counter = 0
494
+ # Prepare history snapshot
495
+ message_history = _prepare_message_history(state)
150
496
 
151
- # Create tool buffer for parallel execution
152
- tool_buffer = ToolBuffer()
153
-
154
- # Track iterations and productivity
155
- max_iterations = state_manager.session.user_config.get("settings", {}).get("max_iterations", 15)
497
+ # Per-request trackers
498
+ tool_buffer = ac.ToolBuffer()
499
+ response_state = ac.ResponseState()
156
500
  unproductive_iterations = 0
157
501
  last_productive_iteration = 0
158
-
159
- # Track response state
160
- response_state = ResponseState()
502
+ react_tool = ReactTool(state_manager=state_manager)
161
503
 
162
504
  try:
163
- # Get message history from session messages
164
- # Create a copy of the message history to avoid modifying the original
165
- message_history = list(state_manager.session.messages)
166
-
167
505
  async with agent.iter(message, message_history=message_history) as agent_run:
168
- # Process nodes iteratively
169
506
  i = 1
170
507
  async for node in agent_run:
171
- state_manager.session.current_iteration = i
172
- state_manager.session.iteration_count = i
173
-
174
- # Handle token-level streaming for model request nodes
175
- Agent, _ = get_agent_tool()
176
- if streaming_callback and STREAMING_AVAILABLE and Agent.is_model_request_node(node):
177
- # Gracefully handle streaming errors from LLM provider
178
- for attempt in range(2): # simple retry once, then degrade gracefully
179
- try:
180
- async with node.stream(agent_run.ctx) as request_stream:
181
- async for event in request_stream:
182
- if isinstance(event, PartDeltaEvent) and isinstance(
183
- event.delta, TextPartDelta
184
- ):
185
- # Stream individual token deltas
186
- if event.delta.content_delta and streaming_callback:
187
- await streaming_callback(event.delta.content_delta)
188
- break # successful streaming; exit retry loop
189
- except Exception as stream_err:
190
- # Log with context and optionally notify UI, then retry once
191
- logger.warning(
192
- "Streaming error (attempt %s/2) req=%s iter=%s: %s",
193
- attempt + 1,
194
- request_id,
195
- i,
196
- stream_err,
197
- exc_info=True,
198
- )
199
- if getattr(state_manager.session, "show_thoughts", False):
200
- from tunacode.ui import console as ui
201
-
202
- await ui.warning(
203
- "⚠️ Streaming failed; retrying once then falling back"
204
- )
205
- # On second failure, degrade gracefully (no streaming)
206
- if attempt == 1:
207
- if getattr(state_manager.session, "show_thoughts", False):
208
- from tunacode.ui import console as ui
209
-
210
- await ui.muted(
211
- "Switching to non-streaming processing for this node"
212
- )
213
- break
214
-
215
- empty_response, empty_reason = await _process_node(
508
+ state.set_iteration(i)
509
+
510
+ # Optional token streaming
511
+ await _maybe_stream_node_tokens(
512
+ node, agent_run.ctx, state_manager, streaming_callback, ctx.request_id, i
513
+ )
514
+
515
+ # Core node processing (delegated to components)
516
+ empty_response, empty_reason = await ac._process_node( # noqa: SLF001 (private but stable in repo)
216
517
  node,
217
518
  tool_callback,
218
519
  state_manager,
@@ -222,285 +523,135 @@ async def process_request(
222
523
  response_state,
223
524
  )
224
525
 
225
- # Handle empty response
526
+ # Handle empty response (aggressive retry prompt)
226
527
  if empty_response:
227
- if not hasattr(state_manager.session, "consecutive_empty_responses"):
228
- state_manager.session.consecutive_empty_responses = 0
229
- state_manager.session.consecutive_empty_responses += 1
230
-
231
- if state_manager.session.consecutive_empty_responses >= 1:
232
- force_action_content = create_empty_response_message(
233
- message,
234
- empty_reason,
235
- state_manager.session.tool_calls,
236
- i,
237
- state_manager,
238
- )
239
- create_user_message(force_action_content, state_manager)
240
-
241
- if state_manager.session.show_thoughts:
242
- from tunacode.ui import console as ui
528
+ if state.increment_empty_response() >= 1:
529
+ await _handle_empty_response(message, empty_reason, i, state)
530
+ state.clear_empty_response()
531
+ else:
532
+ state.clear_empty_response()
243
533
 
244
- await ui.warning(
245
- "\n⚠️ EMPTY RESPONSE FAILURE - AGGRESSIVE RETRY TRIGGERED"
246
- )
247
- await ui.muted(f" Reason: {empty_reason}")
248
- await ui.muted(
249
- f" Recent tools: {get_recent_tools_context(state_manager.session.tool_calls)}"
250
- )
251
- await ui.muted(" Injecting retry guidance prompt")
534
+ # Track whether we produced visible user output this iteration
535
+ if getattr(getattr(node, "result", None), "output", None):
536
+ response_state.has_user_response = True
252
537
 
253
- state_manager.session.consecutive_empty_responses = 0
254
- else:
255
- if hasattr(state_manager.session, "consecutive_empty_responses"):
256
- state_manager.session.consecutive_empty_responses = 0
257
-
258
- if hasattr(node, "result") and node.result and hasattr(node.result, "output"):
259
- if node.result.output:
260
- response_state.has_user_response = True
261
-
262
- # Track productivity - check if any tools were used in this iteration
263
- iteration_had_tools = False
264
- if hasattr(node, "model_response"):
265
- for part in node.model_response.parts:
266
- if hasattr(part, "part_kind") and part.part_kind == "tool-call":
267
- iteration_had_tools = True
268
- break
269
-
270
- if iteration_had_tools:
271
- # Reset unproductive counter
538
+ # Productivity tracking (tool usage signal)
539
+ if _iteration_had_tool_use(node):
272
540
  unproductive_iterations = 0
273
541
  last_productive_iteration = i
274
542
  else:
275
- # Increment unproductive counter
276
543
  unproductive_iterations += 1
277
544
 
278
- # After 3 unproductive iterations, force action
279
- if unproductive_iterations >= 3 and not response_state.task_completed:
280
- no_progress_content = f"""ALERT: No tools executed for {unproductive_iterations} iterations.
281
-
282
- Last productive iteration: {last_productive_iteration}
283
- Current iteration: {i}/{max_iterations}
284
- Task: {message[:200]}...
285
-
286
- You're describing actions but not executing them. You MUST:
287
-
288
- 1. If task is COMPLETE: Start response with TUNACODE DONE:
289
- 2. If task needs work: Execute a tool RIGHT NOW (grep, read_file, bash, etc.)
290
- 3. If stuck: Explain the specific blocker
291
-
292
- NO MORE DESCRIPTIONS. Take ACTION or mark COMPLETE."""
293
-
294
- create_user_message(no_progress_content, state_manager)
295
-
296
- if state_manager.session.show_thoughts:
297
- from tunacode.ui import console as ui
298
-
299
- await ui.warning(
300
- f"⚠️ NO PROGRESS: {unproductive_iterations} iterations without tool usage"
301
- )
302
-
303
- unproductive_iterations = 0
304
-
305
- # REMOVED: Recursive satisfaction check that caused empty responses
306
- # The agent now decides completion using a DONE marker
307
- # This eliminates recursive agent calls and gives control back to the agent
308
-
309
- # Store original query for reference
310
- if not hasattr(state_manager.session, "original_query"):
311
- state_manager.session.original_query = message
312
-
313
- # Display iteration progress if thoughts are enabled
314
- if state_manager.session.show_thoughts:
315
- from tunacode.ui import console as ui
316
-
317
- await ui.muted(f"\nITERATION: {i}/{max_iterations} (Request ID: {request_id})")
545
+ # Force action if no tool usage for several iterations
546
+ if (
547
+ unproductive_iterations >= UNPRODUCTIVE_LIMIT
548
+ and not response_state.task_completed
549
+ ):
550
+ await _force_action_if_unproductive(
551
+ message,
552
+ unproductive_iterations,
553
+ last_productive_iteration,
554
+ i,
555
+ ctx.max_iterations,
556
+ state,
557
+ )
558
+ unproductive_iterations = 0 # reset after nudge
559
+
560
+ await _maybe_force_react_snapshot(
561
+ i,
562
+ state_manager,
563
+ react_tool,
564
+ state.show_thoughts,
565
+ agent_run.ctx,
566
+ )
318
567
 
319
- # Show summary of tools used so far
320
- if state_manager.session.tool_calls:
321
- tool_summary = get_tool_summary(state_manager.session.tool_calls)
568
+ # Optional debug progress
569
+ if state.show_thoughts:
570
+ await ui.muted(
571
+ f"\nITERATION: {i}/{ctx.max_iterations} (Request ID: {ctx.request_id})"
572
+ )
573
+ tool_summary = ac.get_tool_summary(getattr(state.sm.session, "tool_calls", []))
574
+ if tool_summary:
322
575
  summary_str = ", ".join(
323
- [f"{name}: {count}" for name, count in tool_summary.items()]
576
+ f"{name}: {count}" for name, count in tool_summary.items()
324
577
  )
325
578
  await ui.muted(f"TOOLS USED: {summary_str}")
326
579
 
327
- # User clarification: Ask user for guidance when explicitly awaiting
580
+ # Ask for clarification if agent requested it
328
581
  if response_state.awaiting_user_guidance:
329
- _, tools_used_str = create_progress_summary(state_manager.session.tool_calls)
330
-
331
- clarification_content = f"""I need clarification to continue.
332
-
333
- Original request: {getattr(state_manager.session, "original_query", "your request")}
334
-
335
- Progress so far:
336
- - Iterations: {i}
337
- - Tools used: {tools_used_str}
338
-
339
- If the task is complete, I should respond with TUNACODE DONE:
340
- Otherwise, please provide specific guidance on what to do next."""
341
-
342
- create_user_message(clarification_content, state_manager)
343
-
344
- if state_manager.session.show_thoughts:
345
- from tunacode.ui import console as ui
346
-
347
- await ui.muted(
348
- "\n🤔 SEEKING CLARIFICATION: Asking user for guidance on task progress"
349
- )
582
+ await _ask_for_clarification(i, state)
583
+ # Keep the flag set; downstream logic can react to new user input
350
584
 
351
- response_state.awaiting_user_guidance = True
352
-
353
- # Check if task is explicitly completed
585
+ # Early completion
354
586
  if response_state.task_completed:
355
- if state_manager.session.show_thoughts:
356
- from tunacode.ui import console as ui
357
-
587
+ if state.show_thoughts:
358
588
  await ui.success("Task completed successfully")
359
589
  break
360
590
 
361
- if i >= max_iterations and not response_state.task_completed:
362
- _, tools_str = create_progress_summary(state_manager.session.tool_calls)
363
- tools_str = tools_str if tools_str != "No tools used yet" else "No tools used"
364
-
365
- extend_content = f"""I've reached the iteration limit ({max_iterations}).
366
-
367
- Progress summary:
368
- - Tools used: {tools_str}
369
- - Iterations completed: {i}
370
-
371
- The task appears incomplete. Would you like me to:
372
- 1. Continue working (I can extend the limit)
373
- 2. Summarize what I've done and stop
374
- 3. Try a different approach
375
-
376
- Please let me know how to proceed."""
377
-
378
- create_user_message(extend_content, state_manager)
379
-
380
- if state_manager.session.show_thoughts:
381
- from tunacode.ui import console as ui
382
-
591
+ # Reaching iteration cap ask what to do next (no auto-extend by default)
592
+ if i >= ctx.max_iterations and not response_state.task_completed:
593
+ _, tools_str = ac.create_progress_summary(
594
+ getattr(state.sm.session, "tool_calls", [])
595
+ )
596
+ if tools_str == "No tools used yet":
597
+ tools_str = "No tools used"
598
+
599
+ extend_content = (
600
+ f"I've reached the iteration limit ({ctx.max_iterations}).\n\n"
601
+ "Progress summary:\n"
602
+ f"- Tools used: {tools_str}\n"
603
+ f"- Iterations completed: {i}\n\n"
604
+ "The task appears incomplete. Would you like me to:\n"
605
+ "1. Continue working (extend limit)\n"
606
+ "2. Summarize what I've done and stop\n"
607
+ "3. Try a different approach\n\n"
608
+ "Please let me know how to proceed."
609
+ )
610
+ ac.create_user_message(extend_content, state.sm)
611
+ if state.show_thoughts:
383
612
  await ui.muted(
384
- f"\n📊 ITERATION LIMIT: Asking user for guidance at {max_iterations} iterations"
613
+ f"\nITERATION LIMIT: Awaiting user guidance at {ctx.max_iterations} iterations"
385
614
  )
386
-
387
- max_iterations += 5
388
615
  response_state.awaiting_user_guidance = True
616
+ # Do not auto-increase max_iterations here (avoid infinite loops)
389
617
 
390
- # Increment iteration counter
391
618
  i += 1
392
619
 
393
- # Final flush: execute any remaining buffered read-only tools
394
- if tool_callback and tool_buffer.has_tasks():
395
- import time
396
-
397
- from tunacode.ui import console as ui
398
-
399
- buffered_tasks = tool_buffer.flush()
400
- start_time = time.time()
401
-
402
- # Update spinner message for final batch execution
403
- tool_names = [part.tool_name for part, _ in buffered_tasks]
404
- batch_msg = get_batch_description(len(buffered_tasks), tool_names)
405
- await ui.update_spinner_message(
406
- f"[bold #00d7ff]{batch_msg}...[/bold #00d7ff]", state_manager
407
- )
620
+ # Final buffered read-only tasks (batch)
621
+ await _finalize_buffered_tasks(tool_buffer, tool_callback, state)
408
622
 
409
- await ui.muted("\n" + "=" * 60)
410
- await ui.muted(
411
- f"🚀 FINAL BATCH: Executing {len(buffered_tasks)} buffered read-only tools"
412
- )
413
- await ui.muted("=" * 60)
414
-
415
- for idx, (part, node) in enumerate(buffered_tasks, 1):
416
- tool_desc = f" [{idx}] {part.tool_name}"
417
- if hasattr(part, "args") and isinstance(part.args, dict):
418
- if part.tool_name == "read_file" and "file_path" in part.args:
419
- tool_desc += f" → {part.args['file_path']}"
420
- elif part.tool_name == "grep" and "pattern" in part.args:
421
- tool_desc += f" → pattern: '{part.args['pattern']}'"
422
- if "include_files" in part.args:
423
- tool_desc += f", files: '{part.args['include_files']}'"
424
- elif part.tool_name == "list_dir" and "directory" in part.args:
425
- tool_desc += f" → {part.args['directory']}"
426
- elif part.tool_name == "glob" and "pattern" in part.args:
427
- tool_desc += f" → pattern: '{part.args['pattern']}'"
428
- await ui.muted(tool_desc)
429
- await ui.muted("=" * 60)
430
-
431
- await execute_tools_parallel(buffered_tasks, tool_callback)
432
-
433
- elapsed_time = (time.time() - start_time) * 1000
434
- sequential_estimate = len(buffered_tasks) * 100
435
- speedup = sequential_estimate / elapsed_time if elapsed_time > 0 else 1.0
436
-
437
- await ui.muted(
438
- f"✅ Final batch completed in {elapsed_time:.0f}ms "
439
- f"(~{speedup:.1f}x faster than sequential)\n"
440
- )
441
-
442
- # Reset spinner back to thinking
443
- from tunacode.constants import UI_THINKING_MESSAGE
444
-
445
- await ui.update_spinner_message(UI_THINKING_MESSAGE, state_manager)
446
-
447
- # If we need to add a fallback response, create a wrapper
448
- if (
449
- not response_state.has_user_response
450
- and not response_state.task_completed
451
- and i >= max_iterations
452
- and fallback_enabled
453
- ):
454
- patch_tool_messages("Task incomplete", state_manager=state_manager)
623
+ # Build fallback synthesis if needed
624
+ if _should_build_fallback(response_state, i, ctx.max_iterations, ctx.fallback_enabled):
625
+ ac.patch_tool_messages("Task incomplete", state_manager=state_manager)
455
626
  response_state.has_final_synthesis = True
456
-
457
- verbosity = state_manager.session.user_config.get("settings", {}).get(
458
- "fallback_verbosity", "normal"
459
- )
460
- fallback = create_fallback_response(
461
- i,
462
- max_iterations,
463
- state_manager.session.tool_calls,
464
- state_manager.session.messages,
465
- verbosity,
466
- )
467
- comprehensive_output = format_fallback_output(fallback)
468
-
469
- wrapper = AgentRunWrapper(
470
- agent_run, SimpleResult(comprehensive_output), response_state
627
+ comprehensive_output = _build_fallback_output(i, ctx.max_iterations, state)
628
+ wrapper = ac.AgentRunWrapper(
629
+ agent_run, ac.SimpleResult(comprehensive_output), response_state
471
630
  )
472
631
  return wrapper
473
632
 
474
- # For non-fallback cases, we still need to handle the response_state
475
- # Create a minimal wrapper just to add response_state
476
- state_wrapper = AgentRunWithState(agent_run, response_state)
477
- return state_wrapper
633
+ # Normal path: return a wrapper that carries response_state
634
+ return ac.AgentRunWithState(agent_run, response_state)
478
635
 
479
636
  except UserAbortError:
480
637
  raise
481
638
  except ToolBatchingJSONError as e:
482
- logger.error(f"Tool batching JSON error: {e}", exc_info=True)
483
- # Patch orphaned tool messages with error
484
- patch_tool_messages(f"Tool batching failed: {str(e)[:100]}...", state_manager=state_manager)
485
- # Re-raise to be handled by caller
639
+ logger.error("Tool batching JSON error [req=%s]: %s", ctx.request_id, e, exc_info=True)
640
+ ac.patch_tool_messages(
641
+ f"Tool batching failed: {str(e)[:100]}...", state_manager=state_manager
642
+ )
486
643
  raise
487
644
  except Exception as e:
488
- # Include request context to aid debugging
489
- safe_iter = (
490
- state_manager.session.current_iteration
491
- if hasattr(state_manager.session, "current_iteration")
492
- else "?"
493
- )
645
+ # Attach request/iteration context for observability
646
+ safe_iter = getattr(state_manager.session, "current_iteration", "?")
494
647
  logger.error(
495
- f"Error in process_request [req={request_id} iter={safe_iter}]: {e}",
648
+ "Error in process_request [req=%s iter=%s]: %s",
649
+ ctx.request_id,
650
+ safe_iter,
651
+ e,
496
652
  exc_info=True,
497
653
  )
498
- # Patch orphaned tool messages with generic error
499
- patch_tool_messages(
654
+ ac.patch_tool_messages(
500
655
  f"Request processing failed: {str(e)[:100]}...", state_manager=state_manager
501
656
  )
502
- # Re-raise to be handled by caller
503
657
  raise
504
-
505
-
506
- 1