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,112 @@
1
+ """State transition management for agent response processing."""
2
+
3
+ import threading
4
+ from dataclasses import dataclass
5
+ from enum import Enum
6
+
7
+ from tunacode.types import AgentState
8
+
9
+
10
+ class InvalidStateTransitionError(Exception):
11
+ """Raised when an invalid state transition is attempted."""
12
+
13
+ def __init__(self, from_state: Enum, to_state: Enum, message: str | None = None):
14
+ self.from_state = from_state
15
+ self.to_state = to_state
16
+ self.message = message or f"Invalid state transition: {from_state.value} → {to_state.value}"
17
+ super().__init__(self.message)
18
+
19
+
20
+ @dataclass
21
+ class StateTransitionRules:
22
+ """Defines valid state transitions for the agent state machine."""
23
+
24
+ # Valid transitions for each state
25
+ valid_transitions: dict[Enum, set[Enum]]
26
+
27
+ def is_valid_transition(self, from_state: Enum, to_state: Enum) -> bool:
28
+ """Check if a transition between states is valid."""
29
+ return to_state in self.valid_transitions.get(from_state, set())
30
+
31
+ def get_valid_next_states(self, current_state: Enum) -> set[Enum]:
32
+ """Get all valid next states from the current state."""
33
+ return self.valid_transitions.get(current_state, set())
34
+
35
+
36
+ class AgentStateMachine:
37
+ """Thread-safe state machine for agent response processing."""
38
+
39
+ def __init__(self, initial_state: "AgentState", rules: StateTransitionRules):
40
+ """
41
+ Initialize the state machine.
42
+
43
+ Args:
44
+ initial_state: The starting state
45
+ rules: Transition rules defining valid state changes
46
+ """
47
+ self._state = initial_state
48
+ self._rules = rules
49
+ self._lock = threading.RLock() # Reentrant lock for thread safety
50
+ self._completion_detected = False
51
+
52
+ @property
53
+ def current_state(self) -> "AgentState":
54
+ """Get the current state."""
55
+ with self._lock:
56
+ return self._state
57
+
58
+ def transition_to(self, new_state: "AgentState") -> None:
59
+ """
60
+ Transition to a new state.
61
+
62
+ Args:
63
+ new_state: The state to transition to
64
+
65
+ Raises:
66
+ InvalidStateTransitionError: If the transition is not valid
67
+ """
68
+ with self._lock:
69
+ if not self._rules.is_valid_transition(self._state, new_state):
70
+ raise InvalidStateTransitionError(
71
+ self._state,
72
+ new_state,
73
+ f"Invalid state transition: {self._state.value} → {new_state.value}",
74
+ )
75
+
76
+ # Handle self-transitions as no-ops
77
+ if self._state == new_state:
78
+ return
79
+
80
+ self._state = new_state
81
+
82
+ def can_transition_to(self, target_state: "AgentState") -> bool:
83
+ """Check if a transition to the target state is allowed."""
84
+ with self._lock:
85
+ return self._rules.is_valid_transition(self._state, target_state)
86
+
87
+ def set_completion_detected(self, detected: bool = True) -> None:
88
+ """Mark that completion has been detected in the RESPONSE state."""
89
+ with self._lock:
90
+ self._completion_detected = detected
91
+
92
+ def is_completed(self) -> bool:
93
+ """Check if the task is completed (only valid in RESPONSE state)."""
94
+ with self._lock:
95
+ return self._state == AgentState.RESPONSE and self._completion_detected
96
+
97
+ def reset(self, initial_state: "AgentState" = None) -> None:
98
+ """Reset the state machine to initial state."""
99
+ with self._lock:
100
+ self._state = initial_state or AgentState.USER_INPUT
101
+ self._completion_detected = False
102
+
103
+
104
+ # Define the transition rules for the agent state machine
105
+ AGENT_TRANSITION_RULES = StateTransitionRules(
106
+ valid_transitions={
107
+ AgentState.USER_INPUT: {AgentState.ASSISTANT},
108
+ AgentState.ASSISTANT: {AgentState.TOOL_EXECUTION, AgentState.RESPONSE},
109
+ AgentState.TOOL_EXECUTION: {AgentState.RESPONSE},
110
+ AgentState.RESPONSE: {AgentState.ASSISTANT}, # Can transition back to continue
111
+ }
112
+ )
@@ -0,0 +1,271 @@
1
+ """Streaming instrumentation and handling for agent model request nodes.
2
+
3
+ This module encapsulates verbose streaming logic used during
4
+ token-level streaming from the LLM provider. It updates session debug fields
5
+ and streams deltas to the provided callback while being resilient to errors.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Awaitable, Callable
11
+
12
+ from pydantic_ai.messages import PartDeltaEvent, TextPartDelta
13
+
14
+ from tunacode.core.state import StateManager
15
+
16
+
17
+ def _find_overlap_length(pre_text: str, delta_text: str) -> int:
18
+ """Find length of longest pre_text suffix that equals delta_text prefix.
19
+
20
+ This detects when delta_text starts with content already in pre_text,
21
+ so we can avoid emitting duplicate text.
22
+
23
+ Returns:
24
+ Number of characters that overlap (0 if no overlap).
25
+ """
26
+ if not pre_text or not delta_text:
27
+ return 0
28
+
29
+ max_check = min(len(pre_text), len(delta_text))
30
+ for overlap_len in range(max_check, 0, -1):
31
+ if delta_text[:overlap_len] == pre_text[-overlap_len:]:
32
+ return overlap_len
33
+ return 0
34
+
35
+
36
+ async def stream_model_request_node(
37
+ node,
38
+ agent_run_ctx,
39
+ state_manager: StateManager,
40
+ streaming_callback: Callable[[str], Awaitable[None]] | None,
41
+ request_id: str,
42
+ iteration_index: int,
43
+ ) -> None:
44
+ """Stream token deltas for a model request node with detailed instrumentation.
45
+
46
+ This function mirrors the prior inline logic in main.py but is extracted to
47
+ keep main.py lean. On streaming failure, it degrades gracefully to allow
48
+ non-streaming processing of the node.
49
+ """
50
+ if not streaming_callback:
51
+ return
52
+
53
+ # Gracefully handle streaming errors from LLM provider
54
+ try:
55
+ async with node.stream(agent_run_ctx) as request_stream:
56
+ # Initialize per-node debug accumulators
57
+ state_manager.session._debug_raw_stream_accum = ""
58
+ state_manager.session._debug_events = []
59
+ first_delta_logged = False
60
+ debug_event_count = 0
61
+ first_delta_seen = False
62
+ seeded_prefix_sent = False
63
+ pre_first_delta_text: str | None = None
64
+
65
+ # Helper to extract text from a possible final-result object
66
+ def _extract_text(obj) -> str | None:
67
+ try:
68
+ if obj is None:
69
+ return None
70
+ if isinstance(obj, str):
71
+ return obj
72
+ # Common attributes that may hold text
73
+ for attr in ("output", "text", "content", "message"):
74
+ v = getattr(obj, attr, None)
75
+ if isinstance(v, str) and v:
76
+ return v
77
+ # Parts-based result
78
+ parts = getattr(obj, "parts", None)
79
+ if isinstance(parts, list | tuple) and parts:
80
+ texts: list[str] = []
81
+ for p in parts:
82
+ c = getattr(p, "content", None)
83
+ if isinstance(c, str) and c:
84
+ texts.append(c)
85
+ if texts:
86
+ return "".join(texts)
87
+ # Nested .result or .response
88
+ for attr in ("result", "response", "final"):
89
+ v = getattr(obj, attr, None)
90
+ t = _extract_text(v)
91
+ if t:
92
+ return t
93
+ except Exception:
94
+ return None
95
+ return None
96
+
97
+ # Mark stream open
98
+ try:
99
+ import time as _t
100
+
101
+ state_manager.session._debug_events.append(
102
+ f"[src] stream_opened ts_ns={_t.perf_counter_ns()}"
103
+ )
104
+ except Exception:
105
+ pass
106
+
107
+ async for event in request_stream:
108
+ debug_event_count += 1
109
+ # Log first few raw event types for diagnosis
110
+ if debug_event_count <= 5:
111
+ try:
112
+ etype = type(event).__name__
113
+ d = getattr(event, "delta", None)
114
+ dtype = type(d).__name__ if d is not None else None
115
+ c = getattr(d, "content_delta", None) if d is not None else None
116
+ clen = len(c) if isinstance(c, str) else None
117
+ cpreview = repr(c[:5]) if isinstance(c, str) else None
118
+ # Probe common fields on non-delta events to see if they contain text
119
+ r = getattr(event, "result", None)
120
+ rtype = type(r).__name__ if r is not None else None
121
+ rpreview = None
122
+ rplen = None
123
+ # Also inspect event.part if present (e.g., PartStartEvent)
124
+ p = getattr(event, "part", None)
125
+ ptype = type(p).__name__ if p is not None else None
126
+ pkind = getattr(p, "part_kind", None)
127
+ pcontent = getattr(p, "content", None)
128
+ ppreview = repr(pcontent[:20]) if isinstance(pcontent, str) else None
129
+ pplen = len(pcontent) if isinstance(pcontent, str) else None
130
+ try:
131
+ if isinstance(r, str):
132
+ rpreview = repr(r[:20])
133
+ rplen = len(r)
134
+ elif r is not None:
135
+ # Try a few common shapes: .output, .text, .parts
136
+ r_output = getattr(r, "output", None)
137
+ r_text = getattr(r, "text", None)
138
+ r_parts = getattr(r, "parts", None)
139
+ if isinstance(r_output, str):
140
+ rpreview = repr(r_output[:20])
141
+ rplen = len(r_output)
142
+ elif isinstance(r_text, str):
143
+ rpreview = repr(r_text[:20])
144
+ rplen = len(r_text)
145
+ elif isinstance(r_parts, list | tuple) and r_parts:
146
+ # render a compact preview of first textual part
147
+ for _rp in r_parts:
148
+ rc = getattr(_rp, "content", None)
149
+ if isinstance(rc, str) and rc:
150
+ rpreview = repr(rc[:20])
151
+ rplen = len(rc)
152
+ break
153
+ except Exception:
154
+ pass
155
+ event_info = (
156
+ f"[src] event[{debug_event_count}] etype={etype} d={dtype} "
157
+ f"clen={clen} cprev={cpreview} rtype={rtype} "
158
+ f"rprev={rpreview} rlen={rplen} ptype={ptype} "
159
+ f"pkind={pkind} pprev={ppreview} plen={pplen}"
160
+ )
161
+ state_manager.session._debug_events.append(event_info)
162
+ except Exception:
163
+ pass
164
+
165
+ # Attempt to capture pre-first-delta text from non-delta events
166
+ if not first_delta_seen:
167
+ try:
168
+ # event might be a PartStartEvent with .part.content
169
+ if hasattr(event, "part") and hasattr(event.part, "content"):
170
+ pc = event.part.content
171
+ if isinstance(pc, str) and pc and not pc.lstrip().startswith("\n"):
172
+ # capture a short potential prefix
173
+ pre_first_delta_text = pc[:100] if len(pc) > 100 else pc
174
+ except Exception:
175
+ pass
176
+
177
+ # Handle delta events
178
+ if isinstance(event, PartDeltaEvent):
179
+ if isinstance(event.delta, TextPartDelta):
180
+ if event.delta.content_delta is not None and streaming_callback:
181
+ # Seed prefix logic before the first true delta
182
+ if not first_delta_seen:
183
+ first_delta_seen = True
184
+ try:
185
+ delta_text = event.delta.content_delta or ""
186
+ # Only seed when we have a short, safe candidate
187
+ if (
188
+ pre_first_delta_text
189
+ and len(pre_first_delta_text) <= 100
190
+ and not seeded_prefix_sent
191
+ ):
192
+ # Find overlap: longest suffix of pre_first_delta_text
193
+ # that matches a prefix of delta_text
194
+ overlap_len = _find_overlap_length(
195
+ pre_first_delta_text, delta_text
196
+ )
197
+ # Emit the non-overlapping prefix
198
+ prefix_len = len(pre_first_delta_text) - overlap_len
199
+ prefix_to_emit = pre_first_delta_text[:prefix_len]
200
+
201
+ if prefix_to_emit.strip():
202
+ await streaming_callback(prefix_to_emit)
203
+ seeded_prefix_sent = True
204
+ preview_msg = (
205
+ f"[src] seeded_prefix overlap={overlap_len} "
206
+ f"len={len(prefix_to_emit)} "
207
+ f"preview={repr(prefix_to_emit[:20])}"
208
+ )
209
+ state_manager.session._debug_events.append(preview_msg)
210
+ else:
211
+ skip_msg = (
212
+ f"[src] seed_skip overlap={overlap_len} "
213
+ f"delta_len={len(delta_text)} "
214
+ f"pre_len={len(pre_first_delta_text)}"
215
+ )
216
+ state_manager.session._debug_events.append(skip_msg)
217
+ except Exception:
218
+ pass
219
+ finally:
220
+ pre_first_delta_text = None
221
+
222
+ # Record first-delta instrumentation
223
+ if not first_delta_logged:
224
+ try:
225
+ import time as _t
226
+
227
+ ts_ns = _t.perf_counter_ns()
228
+ except Exception:
229
+ ts_ns = 0
230
+ # Store debug event summary for later display
231
+ chunk_preview = repr(
232
+ event.delta.content_delta[:5]
233
+ if event.delta.content_delta
234
+ else ""
235
+ )
236
+ chunk_len = len(event.delta.content_delta or "")
237
+ delta_msg = (
238
+ f"[src] first_delta_received ts_ns={ts_ns} "
239
+ f"chunk_repr={chunk_preview} len={chunk_len}"
240
+ )
241
+ state_manager.session._debug_events.append(delta_msg)
242
+ first_delta_logged = True
243
+
244
+ # Accumulate full raw stream for comparison and forward delta
245
+ delta_text = event.delta.content_delta or ""
246
+ state_manager.session._debug_raw_stream_accum += delta_text
247
+ await streaming_callback(delta_text)
248
+ else:
249
+ # Log empty or non-text deltas encountered
250
+ state_manager.session._debug_events.append(
251
+ "[src] empty_or_nontext_delta_skipped"
252
+ )
253
+ else:
254
+ # Capture any final result text for diagnostics
255
+ try:
256
+ final_text = _extract_text(getattr(event, "result", None))
257
+ if final_text:
258
+ final_msg = (
259
+ f"[src] final_text_preview len={len(final_text)} "
260
+ f"preview={repr(final_text[:20])}"
261
+ )
262
+ state_manager.session._debug_events.append(final_msg)
263
+ except Exception:
264
+ pass
265
+ except Exception:
266
+ # Reset node state to allow graceful degradation to non-streaming mode
267
+ try:
268
+ if hasattr(node, "_did_stream"):
269
+ node._did_stream = False
270
+ except Exception:
271
+ pass
@@ -0,0 +1,40 @@
1
+ """Task completion detection utilities."""
2
+
3
+ import re
4
+
5
+ _COMPLETION_MARKERS = (
6
+ re.compile(r"^\s*TUNACODE\s+DONE:\s*", re.IGNORECASE),
7
+ re.compile(r"^\s*TUNACODE[_\s]+TASK_COMPLETE\s*:?[\s]*", re.IGNORECASE),
8
+ )
9
+
10
+
11
+ def check_task_completion(content: str) -> tuple[bool, str]:
12
+ """
13
+ Check if the content indicates task completion.
14
+
15
+ Args:
16
+ content: The text content to check
17
+
18
+ Returns:
19
+ Tuple of (is_complete, cleaned_content)
20
+ - is_complete: True if task completion marker found
21
+ - cleaned_content: Content with marker removed
22
+ """
23
+ if not content:
24
+ return False, content
25
+
26
+ lines = content.split("\n")
27
+
28
+ for idx, line in enumerate(lines):
29
+ for pattern in _COMPLETION_MARKERS:
30
+ match = pattern.match(line)
31
+ if match:
32
+ remainder = line[match.end() :].strip()
33
+ cleaned_lines = lines[:idx]
34
+ if remainder:
35
+ cleaned_lines.append(remainder)
36
+ cleaned_lines.extend(lines[idx + 1 :])
37
+ cleaned = "\n".join(cleaned_lines).strip()
38
+ return True, cleaned
39
+
40
+ return False, content
@@ -0,0 +1,44 @@
1
+ """Tool buffer for managing parallel execution of read-only tools."""
2
+
3
+ from typing import Any
4
+
5
+
6
+ class ToolBuffer:
7
+ """Buffer for collecting read-only tool calls to execute in parallel."""
8
+
9
+ def __init__(self):
10
+ self.read_only_tasks: list[tuple[Any, Any]] = []
11
+
12
+ def add(self, part: Any, node: Any) -> None:
13
+ """Add a read-only tool call to the buffer."""
14
+ self.read_only_tasks.append((part, node))
15
+
16
+ def flush(self) -> list[tuple[Any, Any]]:
17
+ """Return buffered tasks and clear the buffer."""
18
+ tasks = self.read_only_tasks
19
+ self.read_only_tasks = []
20
+ return tasks
21
+
22
+ def has_tasks(self) -> bool:
23
+ """Check if there are buffered tasks."""
24
+ return len(self.read_only_tasks) > 0
25
+
26
+ def size(self) -> int:
27
+ """Return the number of buffered tasks."""
28
+ return len(self.read_only_tasks)
29
+
30
+ def peek(self) -> list[tuple[Any, Any]]:
31
+ """Return buffered tasks without clearing the buffer."""
32
+ return self.read_only_tasks.copy()
33
+
34
+ def count_by_type(self) -> dict[str, int]:
35
+ """Count buffered tools by type for metrics and debugging."""
36
+ counts: dict[str, int] = {}
37
+ for part, _ in self.read_only_tasks:
38
+ tool_name = getattr(part, "tool_name", "unknown")
39
+ counts[tool_name] = counts.get(tool_name, 0) + 1
40
+ return counts
41
+
42
+ def clear(self) -> None:
43
+ """Clear all buffered tasks without executing them."""
44
+ self.read_only_tasks.clear()
@@ -0,0 +1,101 @@
1
+ """Tool execution and parallelization functionality with automatic retry."""
2
+
3
+ import asyncio
4
+ import os
5
+ import random
6
+ from typing import Any
7
+
8
+ from pydantic_ai import ModelRetry
9
+
10
+ from tunacode.constants import (
11
+ TOOL_MAX_RETRIES,
12
+ TOOL_RETRY_BASE_DELAY,
13
+ TOOL_RETRY_MAX_DELAY,
14
+ )
15
+ from tunacode.exceptions import (
16
+ ConfigurationError,
17
+ FileOperationError,
18
+ ToolExecutionError,
19
+ UserAbortError,
20
+ ValidationError,
21
+ )
22
+ from tunacode.types import ToolCallback
23
+
24
+ # Errors that should NOT be retried - they represent user intent or unrecoverable states
25
+ NON_RETRYABLE_ERRORS = (
26
+ UserAbortError,
27
+ ModelRetry,
28
+ KeyboardInterrupt,
29
+ SystemExit,
30
+ ValidationError,
31
+ ConfigurationError,
32
+ ToolExecutionError,
33
+ FileOperationError,
34
+ )
35
+
36
+
37
+ def _calculate_backoff(attempt: int) -> float:
38
+ """Exponential backoff with jitter."""
39
+ delay = min(TOOL_RETRY_BASE_DELAY * (2 ** (attempt - 1)), TOOL_RETRY_MAX_DELAY)
40
+ jitter = random.uniform(0, delay * 0.1) # nosec B311 - not for crypto
41
+ return delay + jitter
42
+
43
+
44
+ async def execute_tools_parallel(
45
+ tool_calls: list[tuple[Any, Any]], callback: ToolCallback
46
+ ) -> list[Any]:
47
+ """
48
+ Execute multiple tool calls in parallel using asyncio with automatic retry.
49
+
50
+ Each tool gets up to TOOL_MAX_RETRIES attempts before failing.
51
+ Non-retryable errors (user abort, validation, etc.) propagate immediately.
52
+
53
+ Args:
54
+ tool_calls: List of (part, node) tuples
55
+ callback: The tool callback function to execute
56
+
57
+ Returns:
58
+ List of results in the same order as input
59
+
60
+ Raises:
61
+ Exception: Re-raises after all retry attempts exhausted
62
+ """
63
+ max_parallel = int(os.environ.get("TUNACODE_MAX_PARALLEL", os.cpu_count() or 4))
64
+
65
+ async def execute_with_retry(part, node):
66
+ for attempt in range(1, TOOL_MAX_RETRIES + 1):
67
+ try:
68
+ result = await callback(part, node)
69
+
70
+ return result
71
+ except NON_RETRYABLE_ERRORS:
72
+ raise
73
+ except Exception:
74
+ if attempt == TOOL_MAX_RETRIES:
75
+ raise
76
+ backoff = _calculate_backoff(attempt)
77
+ await asyncio.sleep(backoff)
78
+
79
+ raise AssertionError("unreachable")
80
+
81
+ # Execute in batches if we have more tools than max_parallel
82
+ if len(tool_calls) > max_parallel:
83
+ results = []
84
+ for i in range(0, len(tool_calls), max_parallel):
85
+ batch = tool_calls[i : i + max_parallel]
86
+ batch_tasks = [execute_with_retry(part, node) for part, node in batch]
87
+ batch_results = await asyncio.gather(*batch_tasks, return_exceptions=True)
88
+ results.extend(batch_results)
89
+ # Check for errors after each batch
90
+ for result in batch_results:
91
+ if isinstance(result, Exception):
92
+ raise result
93
+ return results
94
+ else:
95
+ tasks = [execute_with_retry(part, node) for part, node in tool_calls]
96
+ results = await asyncio.gather(*tasks, return_exceptions=True)
97
+ # Check for errors - raise the first one
98
+ for result in results:
99
+ if isinstance(result, Exception):
100
+ raise result
101
+ return results
@@ -0,0 +1,37 @@
1
+ """Truncation detection utilities for agent responses."""
2
+
3
+
4
+ def check_for_truncation(combined_content: str) -> bool:
5
+ """Check if content appears to be truncated.
6
+
7
+ Args:
8
+ combined_content: The text content to check for truncation
9
+
10
+ Returns:
11
+ bool: True if the content appears truncated, False otherwise
12
+ """
13
+ if not combined_content:
14
+ return False
15
+
16
+ # Truncation indicators:
17
+ # 1. Ends with "..." or "…" (but not part of a complete sentence)
18
+ # 2. Contains incomplete markdown/code blocks
19
+ # 3. Ends with incomplete parentheses/brackets
20
+
21
+ # Check for ellipsis at end suggesting truncation
22
+ if combined_content.endswith(("...", "…")) and not combined_content.endswith(("....", "….")):
23
+ return True
24
+
25
+ # Check for unclosed markdown code blocks
26
+ code_block_count = combined_content.count("```")
27
+ if code_block_count % 2 != 0:
28
+ return True
29
+
30
+ # Check for unclosed brackets/parentheses (more opens than closes)
31
+ open_brackets = (
32
+ combined_content.count("[") + combined_content.count("(") + combined_content.count("{")
33
+ )
34
+ close_brackets = (
35
+ combined_content.count("]") + combined_content.count(")") + combined_content.count("}")
36
+ )
37
+ return open_brackets > close_brackets