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.
- tunacode/cli/commands/implementations/debug.py +2 -2
- tunacode/cli/commands/implementations/system.py +1 -1
- tunacode/cli/main.py +10 -0
- tunacode/cli/repl.py +28 -8
- tunacode/cli/repl_components/error_recovery.py +2 -2
- tunacode/cli/repl_components/tool_executor.py +1 -1
- tunacode/configuration/defaults.py +2 -2
- tunacode/configuration/key_descriptions.py +275 -0
- tunacode/constants.py +4 -1
- tunacode/core/agents/__init__.py +39 -2
- tunacode/core/agents/agent_components/__init__.py +5 -0
- tunacode/core/agents/agent_components/node_processor.py +24 -3
- tunacode/core/agents/agent_components/streaming.py +268 -0
- tunacode/core/agents/agent_components/task_completion.py +15 -6
- tunacode/core/agents/main.py +531 -380
- tunacode/core/agents/utils.py +1 -129
- tunacode/core/setup/config_wizard.py +2 -1
- tunacode/core/state.py +15 -0
- tunacode/tools/prompts/react_prompt.xml +23 -0
- tunacode/tools/react.py +153 -0
- tunacode/ui/config_dashboard.py +567 -0
- tunacode/ui/panels.py +92 -9
- tunacode/utils/config_comparator.py +340 -0
- {tunacode_cli-0.0.76.dist-info → tunacode_cli-0.0.76.2.dist-info}/METADATA +63 -6
- {tunacode_cli-0.0.76.dist-info → tunacode_cli-0.0.76.2.dist-info}/RECORD +28 -22
- {tunacode_cli-0.0.76.dist-info → tunacode_cli-0.0.76.2.dist-info}/WHEEL +0 -0
- {tunacode_cli-0.0.76.dist-info → tunacode_cli-0.0.76.2.dist-info}/entry_points.txt +0 -0
- {tunacode_cli-0.0.76.dist-info → tunacode_cli-0.0.76.2.dist-info}/licenses/LICENSE +0 -0
tunacode/core/agents/main.py
CHANGED
|
@@ -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
|
|
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
|
-
#
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
-
"
|
|
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
|
-
"""
|
|
88
|
-
from pydantic_ai import Agent
|
|
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
|
|
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
|
-
"""
|
|
100
|
-
return True
|
|
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[
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
#
|
|
143
|
-
|
|
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
|
-
#
|
|
148
|
-
|
|
149
|
-
state_manager.session.batch_counter = 0
|
|
494
|
+
# Prepare history snapshot
|
|
495
|
+
message_history = _prepare_message_history(state)
|
|
150
496
|
|
|
151
|
-
#
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
245
|
-
|
|
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
|
-
|
|
254
|
-
|
|
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
|
-
#
|
|
279
|
-
if
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
580
|
+
# Ask for clarification if agent requested it
|
|
328
581
|
if response_state.awaiting_user_guidance:
|
|
329
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
# Check if task is explicitly completed
|
|
585
|
+
# Early completion
|
|
354
586
|
if response_state.task_completed:
|
|
355
|
-
if
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
tools_str =
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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"\
|
|
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
|
|
394
|
-
|
|
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
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
-
|
|
458
|
-
|
|
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
|
-
#
|
|
475
|
-
|
|
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(
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|