tunacode-cli 0.1.21__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 (174) hide show
  1. tunacode/__init__.py +0 -0
  2. tunacode/cli/textual_repl.tcss +283 -0
  3. tunacode/configuration/__init__.py +1 -0
  4. tunacode/configuration/defaults.py +45 -0
  5. tunacode/configuration/models.py +147 -0
  6. tunacode/configuration/models_registry.json +1 -0
  7. tunacode/configuration/pricing.py +74 -0
  8. tunacode/configuration/settings.py +35 -0
  9. tunacode/constants.py +227 -0
  10. tunacode/core/__init__.py +6 -0
  11. tunacode/core/agents/__init__.py +39 -0
  12. tunacode/core/agents/agent_components/__init__.py +48 -0
  13. tunacode/core/agents/agent_components/agent_config.py +441 -0
  14. tunacode/core/agents/agent_components/agent_helpers.py +290 -0
  15. tunacode/core/agents/agent_components/message_handler.py +99 -0
  16. tunacode/core/agents/agent_components/node_processor.py +477 -0
  17. tunacode/core/agents/agent_components/response_state.py +129 -0
  18. tunacode/core/agents/agent_components/result_wrapper.py +51 -0
  19. tunacode/core/agents/agent_components/state_transition.py +112 -0
  20. tunacode/core/agents/agent_components/streaming.py +271 -0
  21. tunacode/core/agents/agent_components/task_completion.py +40 -0
  22. tunacode/core/agents/agent_components/tool_buffer.py +44 -0
  23. tunacode/core/agents/agent_components/tool_executor.py +101 -0
  24. tunacode/core/agents/agent_components/truncation_checker.py +37 -0
  25. tunacode/core/agents/delegation_tools.py +109 -0
  26. tunacode/core/agents/main.py +545 -0
  27. tunacode/core/agents/prompts.py +66 -0
  28. tunacode/core/agents/research_agent.py +231 -0
  29. tunacode/core/compaction.py +218 -0
  30. tunacode/core/prompting/__init__.py +27 -0
  31. tunacode/core/prompting/loader.py +66 -0
  32. tunacode/core/prompting/prompting_engine.py +98 -0
  33. tunacode/core/prompting/sections.py +50 -0
  34. tunacode/core/prompting/templates.py +69 -0
  35. tunacode/core/state.py +409 -0
  36. tunacode/exceptions.py +313 -0
  37. tunacode/indexing/__init__.py +5 -0
  38. tunacode/indexing/code_index.py +432 -0
  39. tunacode/indexing/constants.py +86 -0
  40. tunacode/lsp/__init__.py +112 -0
  41. tunacode/lsp/client.py +351 -0
  42. tunacode/lsp/diagnostics.py +19 -0
  43. tunacode/lsp/servers.py +101 -0
  44. tunacode/prompts/default_prompt.md +952 -0
  45. tunacode/prompts/research/sections/agent_role.xml +5 -0
  46. tunacode/prompts/research/sections/constraints.xml +14 -0
  47. tunacode/prompts/research/sections/output_format.xml +57 -0
  48. tunacode/prompts/research/sections/tool_use.xml +23 -0
  49. tunacode/prompts/sections/advanced_patterns.xml +255 -0
  50. tunacode/prompts/sections/agent_role.xml +8 -0
  51. tunacode/prompts/sections/completion.xml +10 -0
  52. tunacode/prompts/sections/critical_rules.xml +37 -0
  53. tunacode/prompts/sections/examples.xml +220 -0
  54. tunacode/prompts/sections/output_style.xml +94 -0
  55. tunacode/prompts/sections/parallel_exec.xml +105 -0
  56. tunacode/prompts/sections/search_pattern.xml +100 -0
  57. tunacode/prompts/sections/system_info.xml +6 -0
  58. tunacode/prompts/sections/tool_use.xml +84 -0
  59. tunacode/prompts/sections/user_instructions.xml +3 -0
  60. tunacode/py.typed +0 -0
  61. tunacode/templates/__init__.py +5 -0
  62. tunacode/templates/loader.py +15 -0
  63. tunacode/tools/__init__.py +10 -0
  64. tunacode/tools/authorization/__init__.py +29 -0
  65. tunacode/tools/authorization/context.py +32 -0
  66. tunacode/tools/authorization/factory.py +20 -0
  67. tunacode/tools/authorization/handler.py +58 -0
  68. tunacode/tools/authorization/notifier.py +35 -0
  69. tunacode/tools/authorization/policy.py +19 -0
  70. tunacode/tools/authorization/requests.py +119 -0
  71. tunacode/tools/authorization/rules.py +72 -0
  72. tunacode/tools/bash.py +222 -0
  73. tunacode/tools/decorators.py +213 -0
  74. tunacode/tools/glob.py +353 -0
  75. tunacode/tools/grep.py +468 -0
  76. tunacode/tools/grep_components/__init__.py +9 -0
  77. tunacode/tools/grep_components/file_filter.py +93 -0
  78. tunacode/tools/grep_components/pattern_matcher.py +158 -0
  79. tunacode/tools/grep_components/result_formatter.py +87 -0
  80. tunacode/tools/grep_components/search_result.py +34 -0
  81. tunacode/tools/list_dir.py +205 -0
  82. tunacode/tools/prompts/bash_prompt.xml +10 -0
  83. tunacode/tools/prompts/glob_prompt.xml +7 -0
  84. tunacode/tools/prompts/grep_prompt.xml +10 -0
  85. tunacode/tools/prompts/list_dir_prompt.xml +7 -0
  86. tunacode/tools/prompts/read_file_prompt.xml +9 -0
  87. tunacode/tools/prompts/todoclear_prompt.xml +12 -0
  88. tunacode/tools/prompts/todoread_prompt.xml +16 -0
  89. tunacode/tools/prompts/todowrite_prompt.xml +28 -0
  90. tunacode/tools/prompts/update_file_prompt.xml +9 -0
  91. tunacode/tools/prompts/web_fetch_prompt.xml +11 -0
  92. tunacode/tools/prompts/write_file_prompt.xml +7 -0
  93. tunacode/tools/react.py +111 -0
  94. tunacode/tools/read_file.py +68 -0
  95. tunacode/tools/todo.py +222 -0
  96. tunacode/tools/update_file.py +62 -0
  97. tunacode/tools/utils/__init__.py +1 -0
  98. tunacode/tools/utils/ripgrep.py +311 -0
  99. tunacode/tools/utils/text_match.py +352 -0
  100. tunacode/tools/web_fetch.py +245 -0
  101. tunacode/tools/write_file.py +34 -0
  102. tunacode/tools/xml_helper.py +34 -0
  103. tunacode/types/__init__.py +166 -0
  104. tunacode/types/base.py +94 -0
  105. tunacode/types/callbacks.py +53 -0
  106. tunacode/types/dataclasses.py +121 -0
  107. tunacode/types/pydantic_ai.py +31 -0
  108. tunacode/types/state.py +122 -0
  109. tunacode/ui/__init__.py +6 -0
  110. tunacode/ui/app.py +542 -0
  111. tunacode/ui/commands/__init__.py +430 -0
  112. tunacode/ui/components/__init__.py +1 -0
  113. tunacode/ui/headless/__init__.py +5 -0
  114. tunacode/ui/headless/output.py +72 -0
  115. tunacode/ui/main.py +252 -0
  116. tunacode/ui/renderers/__init__.py +41 -0
  117. tunacode/ui/renderers/errors.py +197 -0
  118. tunacode/ui/renderers/panels.py +550 -0
  119. tunacode/ui/renderers/search.py +314 -0
  120. tunacode/ui/renderers/tools/__init__.py +21 -0
  121. tunacode/ui/renderers/tools/bash.py +247 -0
  122. tunacode/ui/renderers/tools/diagnostics.py +186 -0
  123. tunacode/ui/renderers/tools/glob.py +226 -0
  124. tunacode/ui/renderers/tools/grep.py +228 -0
  125. tunacode/ui/renderers/tools/list_dir.py +198 -0
  126. tunacode/ui/renderers/tools/read_file.py +226 -0
  127. tunacode/ui/renderers/tools/research.py +294 -0
  128. tunacode/ui/renderers/tools/update_file.py +237 -0
  129. tunacode/ui/renderers/tools/web_fetch.py +182 -0
  130. tunacode/ui/repl_support.py +226 -0
  131. tunacode/ui/screens/__init__.py +16 -0
  132. tunacode/ui/screens/model_picker.py +303 -0
  133. tunacode/ui/screens/session_picker.py +181 -0
  134. tunacode/ui/screens/setup.py +218 -0
  135. tunacode/ui/screens/theme_picker.py +90 -0
  136. tunacode/ui/screens/update_confirm.py +69 -0
  137. tunacode/ui/shell_runner.py +129 -0
  138. tunacode/ui/styles/layout.tcss +98 -0
  139. tunacode/ui/styles/modals.tcss +38 -0
  140. tunacode/ui/styles/panels.tcss +81 -0
  141. tunacode/ui/styles/theme-nextstep.tcss +303 -0
  142. tunacode/ui/styles/widgets.tcss +33 -0
  143. tunacode/ui/styles.py +18 -0
  144. tunacode/ui/widgets/__init__.py +23 -0
  145. tunacode/ui/widgets/command_autocomplete.py +62 -0
  146. tunacode/ui/widgets/editor.py +402 -0
  147. tunacode/ui/widgets/file_autocomplete.py +47 -0
  148. tunacode/ui/widgets/messages.py +46 -0
  149. tunacode/ui/widgets/resource_bar.py +182 -0
  150. tunacode/ui/widgets/status_bar.py +98 -0
  151. tunacode/utils/__init__.py +0 -0
  152. tunacode/utils/config/__init__.py +13 -0
  153. tunacode/utils/config/user_configuration.py +91 -0
  154. tunacode/utils/messaging/__init__.py +10 -0
  155. tunacode/utils/messaging/message_utils.py +34 -0
  156. tunacode/utils/messaging/token_counter.py +77 -0
  157. tunacode/utils/parsing/__init__.py +13 -0
  158. tunacode/utils/parsing/command_parser.py +55 -0
  159. tunacode/utils/parsing/json_utils.py +188 -0
  160. tunacode/utils/parsing/retry.py +146 -0
  161. tunacode/utils/parsing/tool_parser.py +267 -0
  162. tunacode/utils/security/__init__.py +15 -0
  163. tunacode/utils/security/command.py +106 -0
  164. tunacode/utils/system/__init__.py +25 -0
  165. tunacode/utils/system/gitignore.py +155 -0
  166. tunacode/utils/system/paths.py +190 -0
  167. tunacode/utils/ui/__init__.py +9 -0
  168. tunacode/utils/ui/file_filter.py +135 -0
  169. tunacode/utils/ui/helpers.py +24 -0
  170. tunacode_cli-0.1.21.dist-info/METADATA +170 -0
  171. tunacode_cli-0.1.21.dist-info/RECORD +174 -0
  172. tunacode_cli-0.1.21.dist-info/WHEEL +4 -0
  173. tunacode_cli-0.1.21.dist-info/entry_points.txt +2 -0
  174. tunacode_cli-0.1.21.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,109 @@
1
+ """
2
+ Module: tunacode.core.agents.delegation_tools
3
+
4
+ Delegation tools for multi-agent workflows using pydantic-ai's delegation pattern.
5
+ """
6
+
7
+ from pydantic_ai import RunContext
8
+
9
+ from tunacode.core.agents.research_agent import create_research_agent
10
+ from tunacode.core.state import StateManager
11
+
12
+
13
+ def create_research_codebase_tool(state_manager: StateManager):
14
+ """Factory to create research_codebase delegation tool with state_manager closure.
15
+
16
+ Args:
17
+ state_manager: StateManager instance to access current model and session state
18
+
19
+ Returns:
20
+ Async function that delegates research queries to specialized research agent
21
+
22
+ Note:
23
+ Progress callback is retrieved from state_manager.session.tool_progress_callback
24
+ at runtime to support dynamic callback updates without agent recreation.
25
+ """
26
+
27
+ async def research_codebase(
28
+ ctx: RunContext[None],
29
+ query: str,
30
+ directories: list[str] | None = None,
31
+ max_files: int = 3,
32
+ ) -> dict:
33
+ """Delegate codebase research to specialized read-only agent.
34
+
35
+ This tool creates a child agent with read-only tools (grep, glob, list_dir, read_file)
36
+ and delegates research queries to it. The child agent's usage is automatically
37
+ aggregated with the parent agent's usage via ctx.usage.
38
+
39
+ Args:
40
+ ctx: RunContext with usage tracking (automatically passed by pydantic-ai)
41
+ query: Research query describing what to find in the codebase
42
+ directories: List of directories to search (defaults to ["."])
43
+ max_files: Maximum number of files to analyze (hard limit: 3, enforced)
44
+
45
+ Returns:
46
+ Structured research findings dict with:
47
+ - relevant_files: list of file paths discovered
48
+ - key_findings: list of important discoveries
49
+ - code_examples: relevant code snippets with explanations
50
+ - recommendations: next steps or areas needing attention
51
+ """
52
+ # Enforce hard limit on max_files
53
+ max_files = min(max_files, 3)
54
+
55
+ if directories is None:
56
+ directories = ["."]
57
+
58
+ # Get current model from session (same model as parent agent)
59
+ model = state_manager.session.current_model
60
+
61
+ # Note: Research agent panel display is handled by node_processor.py
62
+ # which shows a purple panel with query details before execution
63
+
64
+ # Get progress callback from session (set at request time by UI)
65
+ progress_callback = state_manager.session.tool_progress_callback
66
+
67
+ research_agent = create_research_agent(
68
+ model, state_manager, max_files=max_files, progress_callback=progress_callback
69
+ )
70
+
71
+ # Construct research prompt
72
+ prompt = f"""Research the codebase for: {query}
73
+
74
+ Search in directories: {", ".join(directories)}
75
+ Analyze up to {max_files} most relevant files (hard limit: 3 files maximum).
76
+
77
+ Return a structured summary with:
78
+ - relevant_files: list of file paths found
79
+ - key_findings: list of important discoveries
80
+ - code_examples: relevant code snippets with explanations
81
+ - recommendations: next steps or areas needing attention
82
+ """
83
+
84
+ # Delegate to research agent with usage propagation
85
+ try:
86
+ result = await research_agent.run(
87
+ prompt,
88
+ usage=ctx.usage, # Share usage tracking with parent agent
89
+ )
90
+
91
+ return result.output
92
+
93
+ except Exception as e:
94
+ # Catch all delegation errors (validation failures, pydantic-ai errors, etc.)
95
+ # Return structured error response instead of crashing
96
+ return {
97
+ "error": True,
98
+ "error_type": type(e).__name__,
99
+ "error_message": str(e),
100
+ "relevant_files": [],
101
+ "key_findings": [f"Error during research: {str(e)}"],
102
+ "code_examples": [],
103
+ "recommendations": [
104
+ "The research agent encountered an error. "
105
+ "Try simplifying the query or reducing the scope."
106
+ ],
107
+ }
108
+
109
+ return research_codebase
@@ -0,0 +1,545 @@
1
+ """Module: tunacode.core.agents.main
2
+
3
+ Refactored main agent functionality with focused responsibility classes.
4
+ Handles agent creation, configuration, and request processing.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import json
11
+ import uuid
12
+ from collections.abc import Awaitable, Callable
13
+ from dataclasses import dataclass
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ from pydantic_ai import Agent
17
+
18
+ if TYPE_CHECKING:
19
+ from pydantic_ai import Tool # noqa: F401
20
+
21
+ from tunacode.constants import UI_COLORS
22
+ from tunacode.core.compaction import prune_old_tool_outputs
23
+ from tunacode.core.state import StateManager
24
+ from tunacode.exceptions import GlobalRequestTimeoutError, UserAbortError
25
+ from tunacode.tools.react import ReactTool
26
+ from tunacode.types import (
27
+ AgentRun,
28
+ ModelName,
29
+ ToolCallback,
30
+ )
31
+ from tunacode.utils.ui import DotDict
32
+
33
+ from . import agent_components as ac
34
+ from .prompts import format_clarification, format_iteration_limit, format_no_progress
35
+
36
+ colors = DotDict(UI_COLORS)
37
+
38
+ __all__ = [
39
+ "process_request",
40
+ "get_agent_tool",
41
+ "check_query_satisfaction",
42
+ ]
43
+
44
+
45
+ @dataclass
46
+ class AgentConfig:
47
+ """Configuration for agent behavior."""
48
+
49
+ max_iterations: int = 15
50
+ unproductive_limit: int = 3
51
+ forced_react_interval: int = 2
52
+ forced_react_limit: int = 5
53
+ debug_metrics: bool = False
54
+
55
+
56
+ @dataclass(slots=True)
57
+ class RequestContext:
58
+ """Context for a single request."""
59
+
60
+ request_id: str
61
+ max_iterations: int
62
+ debug_metrics: bool
63
+
64
+
65
+ class EmptyResponseHandler:
66
+ """Handles tracking and intervention for empty responses."""
67
+
68
+ def __init__(self, state_manager: StateManager) -> None:
69
+ self.state_manager = state_manager
70
+
71
+ def track(self, is_empty: bool) -> None:
72
+ """Track empty response and increment counter if empty."""
73
+ if is_empty:
74
+ current = getattr(self.state_manager.session, "consecutive_empty_responses", 0)
75
+ self.state_manager.session.consecutive_empty_responses = current + 1
76
+ else:
77
+ self.state_manager.session.consecutive_empty_responses = 0
78
+
79
+ def should_intervene(self) -> bool:
80
+ """Check if intervention is needed (>= 1 consecutive empty)."""
81
+ return getattr(self.state_manager.session, "consecutive_empty_responses", 0) >= 1
82
+
83
+ async def prompt_action(self, message: str, reason: str, iteration: int) -> None:
84
+ """Delegate to agent_components.handle_empty_response."""
85
+
86
+ # Create a minimal state-like object for compatibility
87
+ class StateProxy:
88
+ def __init__(self, sm: StateManager) -> None:
89
+ self.sm = sm
90
+ self.show_thoughts = bool(getattr(sm.session, "show_thoughts", False))
91
+
92
+ state_proxy = StateProxy(self.state_manager)
93
+ await ac.handle_empty_response(message, reason, iteration, state_proxy)
94
+ # Clear after intervention
95
+ self.state_manager.session.consecutive_empty_responses = 0
96
+
97
+
98
+ class IterationManager:
99
+ """Manages iteration tracking, productivity monitoring, and limit handling."""
100
+
101
+ def __init__(self, state_manager: StateManager, config: AgentConfig) -> None:
102
+ self.state_manager = state_manager
103
+ self.config = config
104
+ self.unproductive_iterations = 0
105
+ self.last_productive_iteration = 0
106
+
107
+ def track_productivity(self, had_tool_use: bool, iteration: int) -> None:
108
+ """Track productivity based on tool usage."""
109
+ if had_tool_use:
110
+ self.unproductive_iterations = 0
111
+ self.last_productive_iteration = iteration
112
+ else:
113
+ self.unproductive_iterations += 1
114
+
115
+ def should_force_action(self, response_state: ac.ResponseState) -> bool:
116
+ """Check if action should be forced due to unproductivity."""
117
+ return (
118
+ self.unproductive_iterations >= self.config.unproductive_limit
119
+ and not response_state.task_completed
120
+ )
121
+
122
+ async def handle_iteration_limit(
123
+ self, iteration: int, response_state: ac.ResponseState
124
+ ) -> None:
125
+ """Handle reaching iteration limit."""
126
+ if iteration >= self.config.max_iterations and not response_state.task_completed:
127
+ _, tools_str = ac.create_progress_summary(
128
+ getattr(self.state_manager.session, "tool_calls", [])
129
+ )
130
+ limit_message = format_iteration_limit(self.config.max_iterations, iteration, tools_str)
131
+ ac.create_user_message(limit_message, self.state_manager)
132
+
133
+ response_state.awaiting_user_guidance = True
134
+
135
+ def update_counters(self, iteration: int) -> None:
136
+ """Update session iteration counters."""
137
+ self.state_manager.session.current_iteration = iteration
138
+ self.state_manager.session.iteration_count = iteration
139
+
140
+ async def force_action_if_unproductive(
141
+ self, message: str, iteration: int, response_state: ac.ResponseState
142
+ ) -> None:
143
+ """Force action if unproductive iterations exceeded."""
144
+ if not self.should_force_action(response_state):
145
+ return
146
+
147
+ no_progress_message = format_no_progress(
148
+ message,
149
+ self.unproductive_iterations,
150
+ self.last_productive_iteration,
151
+ iteration,
152
+ self.config.max_iterations,
153
+ )
154
+ ac.create_user_message(no_progress_message, self.state_manager)
155
+
156
+ # Reset after nudge
157
+ self.unproductive_iterations = 0
158
+
159
+ async def ask_for_clarification(self, iteration: int) -> None:
160
+ """Ask user for clarification."""
161
+ _, tools_used_str = ac.create_progress_summary(
162
+ getattr(self.state_manager.session, "tool_calls", [])
163
+ )
164
+ original_query = getattr(self.state_manager.session, "original_query", "your request")
165
+ clarification_message = format_clarification(original_query, iteration, tools_used_str)
166
+ ac.create_user_message(clarification_message, self.state_manager)
167
+
168
+
169
+ class ReactSnapshotManager:
170
+ """Manages forced react snapshots and guidance injection."""
171
+
172
+ def __init__(
173
+ self, state_manager: StateManager, react_tool: ReactTool, config: AgentConfig
174
+ ) -> None:
175
+ self.state_manager = state_manager
176
+ self.react_tool = react_tool
177
+ self.config = config
178
+
179
+ def should_snapshot(self, iteration: int) -> bool:
180
+ """Check if snapshot should be taken."""
181
+ if iteration < self.config.forced_react_interval:
182
+ return False
183
+ if iteration % self.config.forced_react_interval != 0:
184
+ return False
185
+
186
+ forced_calls = getattr(self.state_manager.session, "react_forced_calls", 0)
187
+ return forced_calls < self.config.forced_react_limit
188
+
189
+ async def capture_snapshot(
190
+ self, iteration: int, agent_run_ctx: Any, _show_debug: bool = False
191
+ ) -> None:
192
+ """Capture react snapshot and inject guidance."""
193
+ if not self.should_snapshot(iteration):
194
+ return
195
+
196
+ try:
197
+ await self.react_tool.execute(
198
+ action="think",
199
+ thoughts=f"Auto snapshot after iteration {iteration}",
200
+ next_action="continue",
201
+ )
202
+
203
+ # Increment forced calls counter
204
+ forced_calls = getattr(self.state_manager.session, "react_forced_calls", 0)
205
+ self.state_manager.session.react_forced_calls = forced_calls + 1
206
+
207
+ # Build guidance from last tool call
208
+ timeline = self.state_manager.session.react_scratchpad.get("timeline", [])
209
+ latest = timeline[-1] if timeline else {"thoughts": "?", "next_action": "?"}
210
+ summary = latest.get("thoughts", "")
211
+
212
+ tool_calls = getattr(self.state_manager.session, "tool_calls", [])
213
+ if tool_calls:
214
+ last_tool = tool_calls[-1]
215
+ tool_name = last_tool.get("tool", "tool")
216
+ args = last_tool.get("args", {})
217
+ if isinstance(args, str):
218
+ try:
219
+ args = json.loads(args)
220
+ except (ValueError, TypeError):
221
+ args = {}
222
+
223
+ detail = ""
224
+ if tool_name == "grep" and isinstance(args, dict):
225
+ pattern = args.get("pattern")
226
+ detail = (
227
+ f"Review grep results for pattern '{pattern}'"
228
+ if pattern
229
+ else "Review grep results"
230
+ )
231
+ elif tool_name == "read_file" and isinstance(args, dict):
232
+ path = args.get("file_path") or args.get("filepath")
233
+ detail = (
234
+ f"Extract key notes from {path}" if path else "Summarize read_file output"
235
+ )
236
+ else:
237
+ detail = f"Act on {tool_name} findings"
238
+ else:
239
+ detail = "Plan your first lookup"
240
+
241
+ guidance_entry = (
242
+ f"React snapshot {forced_calls + 1}/{self.config.forced_react_limit} "
243
+ f"at iteration {iteration}: {summary}. Next: {detail}"
244
+ )
245
+
246
+ # Append and trim guidance
247
+ self.state_manager.session.react_guidance.append(guidance_entry)
248
+ if len(self.state_manager.session.react_guidance) > self.config.forced_react_limit:
249
+ self.state_manager.session.react_guidance = (
250
+ self.state_manager.session.react_guidance[-self.config.forced_react_limit :]
251
+ )
252
+
253
+ # CRITICAL: Inject into agent_run.ctx.messages so next LLM call sees guidance
254
+ if agent_run_ctx is not None:
255
+ ctx_messages = getattr(agent_run_ctx, "messages", None)
256
+ if isinstance(ctx_messages, list):
257
+ ModelRequest, _, SystemPromptPart = ac.get_model_messages()
258
+ system_part = SystemPromptPart(
259
+ content=f"[React Guidance] {guidance_entry}",
260
+ part_kind="system-prompt",
261
+ )
262
+ # CLAUDE_ANCHOR[react-system-injection]
263
+ # Append synthetic system message so LLM receives react guidance next turn
264
+ ctx_messages.append(ModelRequest(parts=[system_part], kind="request"))
265
+
266
+ except Exception:
267
+ pass
268
+
269
+
270
+ class RequestOrchestrator:
271
+ """Orchestrates the main request processing loop."""
272
+
273
+ def __init__(
274
+ self,
275
+ message: str,
276
+ model: ModelName,
277
+ state_manager: StateManager,
278
+ tool_callback: ToolCallback | None,
279
+ streaming_callback: Callable[[str], Awaitable[None]] | None,
280
+ tool_result_callback: Callable[..., None] | None = None,
281
+ tool_start_callback: Callable[[str], None] | None = None,
282
+ ) -> None:
283
+ self.message = message
284
+ self.model = model
285
+ self.state_manager = state_manager
286
+ self.tool_callback = tool_callback
287
+ self.streaming_callback = streaming_callback
288
+ self.tool_result_callback = tool_result_callback
289
+ self.tool_start_callback = tool_start_callback
290
+
291
+ # Initialize config from session settings
292
+ user_config = getattr(state_manager.session, "user_config", {}) or {}
293
+ settings = user_config.get("settings", {})
294
+ self.config = AgentConfig(
295
+ max_iterations=int(settings.get("max_iterations", 15)),
296
+ unproductive_limit=3,
297
+ forced_react_interval=2,
298
+ forced_react_limit=5,
299
+ debug_metrics=bool(settings.get("debug_metrics", False)),
300
+ )
301
+
302
+ # Initialize managers
303
+ self.empty_handler = EmptyResponseHandler(state_manager)
304
+ self.iteration_manager = IterationManager(state_manager, self.config)
305
+ self.react_manager = ReactSnapshotManager(
306
+ state_manager, ReactTool(state_manager=state_manager), self.config
307
+ )
308
+
309
+ def _init_request_context(self) -> RequestContext:
310
+ """Initialize request context with ID and config."""
311
+ req_id = str(uuid.uuid4())[:8]
312
+ self.state_manager.session.request_id = req_id
313
+
314
+ return RequestContext(
315
+ request_id=req_id,
316
+ max_iterations=self.config.max_iterations,
317
+ debug_metrics=self.config.debug_metrics,
318
+ )
319
+
320
+ def _reset_session_state(self) -> None:
321
+ """Reset/initialize fields needed for a new run."""
322
+ self.state_manager.session.current_iteration = 0
323
+ self.state_manager.session.iteration_count = 0
324
+ self.state_manager.session.tool_calls = []
325
+ self.state_manager.session.tool_call_args_by_id = {}
326
+ self.state_manager.session.react_forced_calls = 0
327
+ self.state_manager.session.react_guidance = []
328
+
329
+ # Counter used by other subsystems; initialize if absent
330
+ if not hasattr(self.state_manager.session, "batch_counter"):
331
+ self.state_manager.session.batch_counter = 0
332
+
333
+ # Track empty response streaks
334
+ self.state_manager.session.consecutive_empty_responses = 0
335
+
336
+ self.state_manager.session.original_query = ""
337
+
338
+ def _set_original_query_once(self, query: str) -> None:
339
+ """Set original query if not already set."""
340
+ if not getattr(self.state_manager.session, "original_query", None):
341
+ self.state_manager.session.original_query = query
342
+
343
+ async def run(self) -> AgentRun:
344
+ """Run the main request processing loop with optional global timeout."""
345
+ from tunacode.core.agents.agent_components.agent_config import (
346
+ _coerce_global_request_timeout,
347
+ )
348
+
349
+ timeout = _coerce_global_request_timeout(self.state_manager)
350
+ if timeout is None:
351
+ return await self._run_impl()
352
+
353
+ try:
354
+ return await asyncio.wait_for(self._run_impl(), timeout=timeout)
355
+ except TimeoutError as e:
356
+ raise GlobalRequestTimeoutError(timeout) from e
357
+
358
+ async def _run_impl(self) -> AgentRun:
359
+ """Internal implementation of request processing loop."""
360
+ ctx = self._init_request_context()
361
+ self._reset_session_state()
362
+ self._set_original_query_once(self.message)
363
+
364
+ # Acquire agent
365
+ agent = ac.get_or_create_agent(self.model, self.state_manager)
366
+
367
+ # Prune old tool outputs directly in session (persisted)
368
+ session_messages = self.state_manager.session.messages
369
+ _, tokens_reclaimed = prune_old_tool_outputs(session_messages, self.model)
370
+
371
+ # Prepare history snapshot (now pruned)
372
+ message_history = list(session_messages)
373
+
374
+ # Per-request trackers
375
+ tool_buffer = ac.ToolBuffer()
376
+ response_state = ac.ResponseState()
377
+
378
+ try:
379
+ async with agent.iter(self.message, message_history=message_history) as agent_run:
380
+ i = 1
381
+ async for node in agent_run:
382
+ self.iteration_manager.update_counters(i)
383
+
384
+ # Optional token streaming
385
+ await _maybe_stream_node_tokens(
386
+ node,
387
+ agent_run.ctx,
388
+ self.state_manager,
389
+ self.streaming_callback,
390
+ ctx.request_id,
391
+ i,
392
+ )
393
+
394
+ # Core node processing
395
+ empty_response, empty_reason = await ac._process_node( # noqa: SLF001
396
+ node,
397
+ self.tool_callback,
398
+ self.state_manager,
399
+ tool_buffer,
400
+ self.streaming_callback,
401
+ response_state,
402
+ self.tool_result_callback,
403
+ self.tool_start_callback,
404
+ )
405
+
406
+ # Handle empty response
407
+ self.empty_handler.track(empty_response)
408
+ if empty_response and self.empty_handler.should_intervene():
409
+ await self.empty_handler.prompt_action(self.message, empty_reason, i)
410
+
411
+ # Track whether we produced visible user output this iteration
412
+ if getattr(getattr(node, "result", None), "output", None):
413
+ response_state.has_user_response = True
414
+
415
+ # Productivity tracking
416
+ had_tool_use = _iteration_had_tool_use(node)
417
+ self.iteration_manager.track_productivity(had_tool_use, i)
418
+
419
+ # Force action if unproductive
420
+ await self.iteration_manager.force_action_if_unproductive(
421
+ self.message, i, response_state
422
+ )
423
+
424
+ # Force react snapshot
425
+ show_thoughts = bool(
426
+ getattr(self.state_manager.session, "show_thoughts", False)
427
+ )
428
+ await self.react_manager.capture_snapshot(i, agent_run.ctx, show_thoughts)
429
+
430
+ # Ask for clarification if agent requested it
431
+ if response_state.awaiting_user_guidance:
432
+ await self.iteration_manager.ask_for_clarification(i)
433
+
434
+ # Early completion
435
+ if response_state.task_completed:
436
+ break
437
+
438
+ # Handle iteration limit
439
+ await self.iteration_manager.handle_iteration_limit(i, response_state)
440
+
441
+ i += 1
442
+
443
+ await _finalize_buffered_tasks(
444
+ tool_buffer,
445
+ self.tool_callback,
446
+ self.state_manager,
447
+ )
448
+
449
+ # Return wrapper that carries response_state
450
+ return ac.AgentRunWithState(agent_run, response_state)
451
+
452
+ except UserAbortError:
453
+ raise
454
+
455
+
456
+ # Utility functions
457
+
458
+
459
+ async def _maybe_stream_node_tokens(
460
+ node: Any,
461
+ agent_run_ctx: Any,
462
+ state_manager: StateManager,
463
+ streaming_cb: Callable[[str], Awaitable[None]] | None,
464
+ request_id: str,
465
+ iteration_index: int,
466
+ ) -> None:
467
+ """Stream tokens from model request nodes if callback provided.
468
+
469
+ Reference: main.py lines 146-161
470
+ """
471
+ if not streaming_cb:
472
+ return
473
+
474
+ # Delegate to component streaming helper
475
+ if Agent.is_model_request_node(node): # type: ignore[attr-defined]
476
+ await ac.stream_model_request_node(
477
+ node, agent_run_ctx, state_manager, streaming_cb, request_id, iteration_index
478
+ )
479
+
480
+
481
+ def _iteration_had_tool_use(node: Any) -> bool:
482
+ """Inspect the node to see if model responded with any tool-call parts.
483
+
484
+ Reference: main.py lines 164-171
485
+ """
486
+ if hasattr(node, "model_response"):
487
+ for part in getattr(node.model_response, "parts", []):
488
+ # pydantic-ai annotates tool calls; be resilient to attr differences
489
+ if getattr(part, "part_kind", None) == "tool-call":
490
+ return True
491
+ return False
492
+
493
+
494
+ async def _finalize_buffered_tasks(
495
+ tool_buffer: ac.ToolBuffer,
496
+ tool_callback: ToolCallback | None,
497
+ state_manager: StateManager,
498
+ ) -> None:
499
+ """Finalize and execute any buffered read-only tasks."""
500
+ if not tool_callback or not tool_buffer.has_tasks():
501
+ return
502
+
503
+ buffered_tasks = tool_buffer.flush()
504
+
505
+ # Execute
506
+ await ac.execute_tools_parallel(buffered_tasks, tool_callback)
507
+
508
+
509
+ def get_agent_tool() -> tuple[type[Agent], type[Tool]]:
510
+ """Return Agent and Tool classes without importing at module load time."""
511
+ from pydantic_ai import Agent as AgentCls
512
+ from pydantic_ai import Tool as ToolCls
513
+
514
+ return AgentCls, ToolCls
515
+
516
+
517
+ async def check_query_satisfaction(
518
+ agent: Agent,
519
+ original_query: str,
520
+ response: str,
521
+ state_manager: StateManager,
522
+ ) -> bool:
523
+ """Legacy hook for compatibility; completion still signaled via DONE marker."""
524
+ return True
525
+
526
+
527
+ async def process_request(
528
+ message: str,
529
+ model: ModelName,
530
+ state_manager: StateManager,
531
+ tool_callback: ToolCallback | None = None,
532
+ streaming_callback: Callable[[str], Awaitable[None]] | None = None,
533
+ tool_result_callback: Callable[..., None] | None = None,
534
+ tool_start_callback: Callable[[str], None] | None = None,
535
+ ) -> AgentRun:
536
+ orchestrator = RequestOrchestrator(
537
+ message,
538
+ model,
539
+ state_manager,
540
+ tool_callback,
541
+ streaming_callback,
542
+ tool_result_callback,
543
+ tool_start_callback,
544
+ )
545
+ return await orchestrator.run()