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

@@ -7,6 +7,8 @@ This package provides a modular command system with:
7
7
 
8
8
  The main public API provides backward compatibility with the original
9
9
  commands.py module while enabling better organization and maintainability.
10
+
11
+ CLAUDE_ANCHOR[commands-module]: Command registry and dispatch system
10
12
  """
11
13
 
12
14
  # Import base classes and infrastructure
@@ -1,4 +1,7 @@
1
- """Command registry and factory for TunaCode CLI commands."""
1
+ """Command registry and factory for TunaCode CLI commands.
2
+
3
+ CLAUDE_ANCHOR[command-registry]: Central command registration and execution
4
+ """
2
5
 
3
6
  from dataclasses import dataclass
4
7
  from typing import Any, Dict, List, Optional, Type
tunacode/cli/repl.py CHANGED
@@ -3,6 +3,8 @@ Module: tunacode.cli.repl
3
3
 
4
4
  Interactive REPL (Read-Eval-Print Loop) implementation for TunaCode.
5
5
  Handles user input, command processing, and agent interaction in an interactive shell.
6
+
7
+ CLAUDE_ANCHOR[repl-module]: Core REPL loop and user interaction handling
6
8
  """
7
9
 
8
10
  # ============================================================================
@@ -41,7 +43,7 @@ MSG_TOOL_INTERRUPTED = "Tool execution was interrupted"
41
43
  MSG_REQUEST_CANCELLED = "Request cancelled"
42
44
  MSG_SESSION_ENDED = "Session ended. Happy coding!"
43
45
  MSG_AGENT_BUSY = "Agent is busy, press Ctrl+C to interrupt."
44
- MSG_HIT_CTRL_C = "Hit Ctrl+C again to exit"
46
+ MSG_HIT_ABORT_KEY = "Hit ESC or Ctrl+C again to exit"
45
47
  SHELL_ENV_VAR = "SHELL"
46
48
  DEFAULT_SHELL = "bash"
47
49
 
@@ -94,7 +96,10 @@ async def _handle_command(command: str, state_manager: StateManager) -> CommandR
94
96
 
95
97
 
96
98
  async def process_request(text: str, state_manager: StateManager, output: bool = True):
97
- """Process input using the agent, handling cancellation safely."""
99
+ """Process input using the agent, handling cancellation safely.
100
+
101
+ CLAUDE_ANCHOR[process-request-repl]: REPL's main request processor with error handling
102
+ """
98
103
  import uuid
99
104
 
100
105
  # Generate a unique ID for this request for correlated logging
@@ -245,7 +250,8 @@ async def process_request(text: str, state_manager: StateManager, output: bool =
245
250
  async def repl(state_manager: StateManager):
246
251
  """Main REPL loop that handles user interaction and input processing."""
247
252
  action = None
248
- ctrl_c_pressed = False
253
+ abort_pressed = False
254
+ last_abort_time = 0.0
249
255
 
250
256
  model_name = state_manager.session.current_model
251
257
  max_tokens = (
@@ -270,16 +276,26 @@ async def repl(state_manager: StateManager):
270
276
  try:
271
277
  line = await ui.multiline_input(state_manager, _command_registry)
272
278
  except UserAbortError:
273
- if ctrl_c_pressed:
279
+ import time
280
+
281
+ current_time = time.time()
282
+
283
+ # Reset if more than 3 seconds have passed
284
+ if current_time - last_abort_time > 3.0:
285
+ abort_pressed = False
286
+
287
+ if abort_pressed:
274
288
  break
275
- ctrl_c_pressed = True
276
- await ui.warning(MSG_HIT_CTRL_C)
289
+
290
+ abort_pressed = True
291
+ last_abort_time = current_time
292
+ await ui.warning(MSG_HIT_ABORT_KEY)
277
293
  continue
278
294
 
279
295
  if not line:
280
296
  continue
281
297
 
282
- ctrl_c_pressed = False
298
+ abort_pressed = False
283
299
 
284
300
  if line.lower() in ["exit", "quit"]:
285
301
  break
@@ -43,8 +43,9 @@ async def tool_handler(part, state_manager: StateManager):
43
43
  if tool_handler_instance.should_confirm(part.tool_name):
44
44
  await ui.info(f"Tool({part.tool_name})")
45
45
 
46
- if not state_manager.session.is_streaming_active and state_manager.session.spinner:
47
- state_manager.session.spinner.stop()
46
+ # Keep spinner running during tool execution - it will be updated with tool status
47
+ # if not state_manager.session.is_streaming_active and state_manager.session.spinner:
48
+ # state_manager.session.spinner.stop()
48
49
 
49
50
  streaming_panel = None
50
51
  if state_manager.session.is_streaming_active and hasattr(
@@ -80,5 +81,6 @@ async def tool_handler(part, state_manager: StateManager):
80
81
  if streaming_panel and tool_handler_instance.should_confirm(part.tool_name):
81
82
  await streaming_panel.start()
82
83
 
83
- if not state_manager.session.is_streaming_active and state_manager.session.spinner:
84
- state_manager.session.spinner.start()
84
+ # Spinner continues running - no need to restart
85
+ # if not state_manager.session.is_streaming_active and state_manager.session.spinner:
86
+ # state_manager.session.spinner.start()
tunacode/constants.py CHANGED
@@ -9,7 +9,7 @@ from enum import Enum
9
9
 
10
10
  # Application info
11
11
  APP_NAME = "TunaCode"
12
- APP_VERSION = "0.0.53"
12
+ APP_VERSION = "0.0.55"
13
13
 
14
14
 
15
15
  # File patterns
@@ -1,6 +1,17 @@
1
1
  """Agent components package for modular agent functionality."""
2
2
 
3
3
  from .agent_config import get_or_create_agent
4
+ from .agent_helpers import (
5
+ create_empty_response_message,
6
+ create_fallback_response,
7
+ create_progress_summary,
8
+ create_user_message,
9
+ format_fallback_output,
10
+ get_recent_tools_context,
11
+ get_tool_description,
12
+ get_tool_summary,
13
+ get_user_prompt_part_class,
14
+ )
4
15
  from .json_tool_parser import extract_and_execute_tool_calls, parse_json_tool_calls
5
16
  from .message_handler import get_model_messages, patch_tool_messages
6
17
  from .node_processor import _process_node
@@ -24,4 +35,13 @@ __all__ = [
24
35
  "check_task_completion",
25
36
  "ToolBuffer",
26
37
  "execute_tools_parallel",
38
+ "create_empty_response_message",
39
+ "create_fallback_response",
40
+ "create_progress_summary",
41
+ "create_user_message",
42
+ "format_fallback_output",
43
+ "get_recent_tools_context",
44
+ "get_tool_description",
45
+ "get_tool_summary",
46
+ "get_user_prompt_part_class",
27
47
  ]
@@ -0,0 +1,219 @@
1
+ """Helper functions for agent operations to reduce code duplication."""
2
+
3
+ from typing import Any
4
+
5
+ from tunacode.core.state import StateManager
6
+ from tunacode.types import FallbackResponse
7
+
8
+
9
+ class UserPromptPartFallback:
10
+ """Fallback class for UserPromptPart when pydantic_ai is not available."""
11
+
12
+ def __init__(self, content: str, part_kind: str):
13
+ self.content = content
14
+ self.part_kind = part_kind
15
+
16
+
17
+ # Cache for UserPromptPart class
18
+ _USER_PROMPT_PART_CLASS = None
19
+
20
+
21
+ def get_user_prompt_part_class():
22
+ """Get UserPromptPart class with caching and fallback for test environment."""
23
+ global _USER_PROMPT_PART_CLASS
24
+
25
+ if _USER_PROMPT_PART_CLASS is not None:
26
+ return _USER_PROMPT_PART_CLASS
27
+
28
+ try:
29
+ import importlib
30
+
31
+ messages = importlib.import_module("pydantic_ai.messages")
32
+ _USER_PROMPT_PART_CLASS = getattr(messages, "UserPromptPart", None)
33
+
34
+ if _USER_PROMPT_PART_CLASS is None:
35
+ _USER_PROMPT_PART_CLASS = UserPromptPartFallback
36
+ except Exception:
37
+ _USER_PROMPT_PART_CLASS = UserPromptPartFallback
38
+
39
+ return _USER_PROMPT_PART_CLASS
40
+
41
+
42
+ def create_user_message(content: str, state_manager: StateManager):
43
+ """Create a user message and add it to the session messages."""
44
+ from .message_handler import get_model_messages
45
+
46
+ model_request_cls = get_model_messages()[0]
47
+ UserPromptPart = get_user_prompt_part_class()
48
+ user_prompt_part = UserPromptPart(content=content, part_kind="user-prompt")
49
+ message = model_request_cls(parts=[user_prompt_part], kind="request")
50
+ state_manager.session.messages.append(message)
51
+ return message
52
+
53
+
54
+ def get_tool_summary(tool_calls: list[dict[str, Any]]) -> dict[str, int]:
55
+ """Generate a summary of tool usage from tool calls."""
56
+ tool_summary: dict[str, int] = {}
57
+ for tc in tool_calls:
58
+ tool_name = tc.get("tool", "unknown")
59
+ tool_summary[tool_name] = tool_summary.get(tool_name, 0) + 1
60
+ return tool_summary
61
+
62
+
63
+ def get_tool_description(tool_name: str, tool_args: dict[str, Any]) -> str:
64
+ """Get a descriptive string for a tool call."""
65
+ tool_desc = tool_name
66
+ if tool_name in ["grep", "glob"] and isinstance(tool_args, dict):
67
+ pattern = tool_args.get("pattern", "")
68
+ tool_desc = f"{tool_name}('{pattern}')"
69
+ elif tool_name == "read_file" and isinstance(tool_args, dict):
70
+ path = tool_args.get("file_path", tool_args.get("filepath", ""))
71
+ tool_desc = f"{tool_name}('{path}')"
72
+ return tool_desc
73
+
74
+
75
+ def get_recent_tools_context(tool_calls: list[dict[str, Any]], limit: int = 3) -> str:
76
+ """Get a context string describing recent tool usage."""
77
+ if not tool_calls:
78
+ return "No tools used yet"
79
+
80
+ last_tools = []
81
+ for tc in tool_calls[-limit:]:
82
+ tool_name = tc.get("tool", "unknown")
83
+ tool_args = tc.get("args", {})
84
+ tool_desc = get_tool_description(tool_name, tool_args)
85
+ last_tools.append(tool_desc)
86
+
87
+ return f"Recent tools: {', '.join(last_tools)}"
88
+
89
+
90
+ def create_empty_response_message(
91
+ message: str,
92
+ empty_reason: str,
93
+ tool_calls: list[dict[str, Any]],
94
+ iteration: int,
95
+ state_manager: StateManager,
96
+ ) -> str:
97
+ """Create an aggressive message for handling empty responses."""
98
+ tools_context = get_recent_tools_context(tool_calls)
99
+
100
+ content = f"""FAILURE DETECTED: You returned {("an " + empty_reason if empty_reason != "empty" else "an empty")} response.
101
+
102
+ This is UNACCEPTABLE. You FAILED to produce output.
103
+
104
+ Task: {message[:200]}...
105
+ {tools_context}
106
+ Current iteration: {iteration}
107
+
108
+ TRY AGAIN RIGHT NOW:
109
+
110
+ 1. If your search returned no results → Try a DIFFERENT search pattern
111
+ 2. If you found what you need → Use TUNACODE_TASK_COMPLETE
112
+ 3. If you're stuck → EXPLAIN SPECIFICALLY what's blocking you
113
+ 4. If you need to explore → Use list_dir or broader searches
114
+
115
+ YOU MUST PRODUCE REAL OUTPUT IN THIS RESPONSE. NO EXCUSES.
116
+ EXECUTE A TOOL OR PROVIDE SUBSTANTIAL CONTENT.
117
+ DO NOT RETURN ANOTHER EMPTY RESPONSE."""
118
+
119
+ return content
120
+
121
+
122
+ def create_progress_summary(tool_calls: list[dict[str, Any]]) -> tuple[dict[str, int], str]:
123
+ """Create a progress summary from tool calls."""
124
+ tool_summary = get_tool_summary(tool_calls)
125
+
126
+ if tool_summary:
127
+ summary_str = ", ".join([f"{name}: {count}" for name, count in tool_summary.items()])
128
+ else:
129
+ summary_str = "No tools used yet"
130
+
131
+ return tool_summary, summary_str
132
+
133
+
134
+ def create_fallback_response(
135
+ iterations: int,
136
+ max_iterations: int,
137
+ tool_calls: list[dict[str, Any]],
138
+ messages: list[Any],
139
+ verbosity: str = "normal",
140
+ ) -> FallbackResponse:
141
+ """Create a comprehensive fallback response when iteration limit is reached."""
142
+ fallback = FallbackResponse(
143
+ summary="Reached maximum iterations without producing a final response.",
144
+ progress=f"Completed {iterations} iterations (limit: {max_iterations})",
145
+ )
146
+
147
+ # Extract context from messages
148
+ tool_calls_summary = []
149
+ files_modified = set()
150
+ commands_run = []
151
+
152
+ for msg in messages:
153
+ if hasattr(msg, "parts"):
154
+ for part in msg.parts:
155
+ if hasattr(part, "part_kind") and part.part_kind == "tool-call":
156
+ tool_name = getattr(part, "tool_name", "unknown")
157
+ tool_calls_summary.append(tool_name)
158
+
159
+ # Track specific operations
160
+ if tool_name in ["write_file", "update_file"] and hasattr(part, "args"):
161
+ if isinstance(part.args, dict) and "file_path" in part.args:
162
+ files_modified.add(part.args["file_path"])
163
+ elif tool_name in ["run_command", "bash"] and hasattr(part, "args"):
164
+ if isinstance(part.args, dict) and "command" in part.args:
165
+ commands_run.append(part.args["command"])
166
+
167
+ if verbosity in ["normal", "detailed"]:
168
+ # Add what was attempted
169
+ if tool_calls_summary:
170
+ tool_counts: dict[str, int] = {}
171
+ for tool in tool_calls_summary:
172
+ tool_counts[tool] = tool_counts.get(tool, 0) + 1
173
+
174
+ fallback.issues.append(f"Executed {len(tool_calls_summary)} tool calls:")
175
+ for tool, count in sorted(tool_counts.items()):
176
+ fallback.issues.append(f" • {tool}: {count}x")
177
+
178
+ if verbosity == "detailed":
179
+ if files_modified:
180
+ fallback.issues.append(f"\nFiles modified ({len(files_modified)}):")
181
+ for f in sorted(files_modified)[:5]:
182
+ fallback.issues.append(f" • {f}")
183
+ if len(files_modified) > 5:
184
+ fallback.issues.append(f" • ... and {len(files_modified) - 5} more")
185
+
186
+ if commands_run:
187
+ fallback.issues.append(f"\nCommands executed ({len(commands_run)}):")
188
+ for cmd in commands_run[:3]:
189
+ display_cmd = cmd if len(cmd) <= 60 else cmd[:57] + "..."
190
+ fallback.issues.append(f" • {display_cmd}")
191
+ if len(commands_run) > 3:
192
+ fallback.issues.append(f" • ... and {len(commands_run) - 3} more")
193
+
194
+ # Add helpful next steps
195
+ fallback.next_steps.append("The task may be too complex - try breaking it into smaller steps")
196
+ fallback.next_steps.append("Check the output above for any errors or partial progress")
197
+ if files_modified:
198
+ fallback.next_steps.append("Review modified files to see what changes were made")
199
+
200
+ return fallback
201
+
202
+
203
+ def format_fallback_output(fallback: FallbackResponse) -> str:
204
+ """Format a fallback response into a comprehensive output string."""
205
+ output_parts = [fallback.summary, ""]
206
+
207
+ if fallback.progress:
208
+ output_parts.append(f"Progress: {fallback.progress}")
209
+
210
+ if fallback.issues:
211
+ output_parts.append("\nWhat happened:")
212
+ output_parts.extend(fallback.issues)
213
+
214
+ if fallback.next_steps:
215
+ output_parts.append("\nSuggested next steps:")
216
+ for step in fallback.next_steps:
217
+ output_parts.append(f" • {step}")
218
+
219
+ return "\n".join(output_parts)
@@ -6,10 +6,12 @@ from typing import Any, Awaitable, Callable, Optional, Tuple
6
6
  from tunacode.core.logging.logger import get_logger
7
7
  from tunacode.core.state import StateManager
8
8
  from tunacode.types import UsageTrackerProtocol
9
+ from tunacode.ui.tool_descriptions import get_batch_description, get_tool_description
9
10
 
10
11
  from .response_state import ResponseState
11
12
  from .task_completion import check_task_completion
12
13
  from .tool_buffer import ToolBuffer
14
+ from .truncation_checker import check_for_truncation
13
15
 
14
16
  logger = get_logger(__name__)
15
17
 
@@ -171,7 +173,7 @@ async def _process_node(
171
173
  # Check for truncation patterns
172
174
  if all_content_parts:
173
175
  combined_content = " ".join(all_content_parts).strip()
174
- appears_truncated = _check_for_truncation(combined_content)
176
+ appears_truncated = check_for_truncation(combined_content)
175
177
 
176
178
  # If we only got empty content and no tool calls, we should NOT consider this a valid response
177
179
  # This prevents the agent from stopping when it gets empty responses
@@ -229,79 +231,6 @@ async def _process_node(
229
231
  return False, None
230
232
 
231
233
 
232
- def _check_for_truncation(combined_content: str) -> bool:
233
- """Check if content appears to be truncated."""
234
- if not combined_content:
235
- return False
236
-
237
- # Truncation indicators:
238
- # 1. Ends with "..." or "…" (but not part of a complete sentence)
239
- # 2. Ends mid-word (no punctuation, space, or complete word)
240
- # 3. Contains incomplete markdown/code blocks
241
- # 4. Ends with incomplete parentheses/brackets
242
-
243
- # Check for ellipsis at end suggesting truncation
244
- if combined_content.endswith(("...", "…")) and not combined_content.endswith(("....", "….")):
245
- return True
246
-
247
- # Check for mid-word truncation (ends with letters but no punctuation)
248
- if combined_content and combined_content[-1].isalpha():
249
- # Look for incomplete words by checking if last "word" seems cut off
250
- words = combined_content.split()
251
- if words:
252
- last_word = words[-1]
253
- # Common complete word endings vs likely truncations
254
- complete_endings = (
255
- "ing",
256
- "ed",
257
- "ly",
258
- "er",
259
- "est",
260
- "tion",
261
- "ment",
262
- "ness",
263
- "ity",
264
- "ous",
265
- "ive",
266
- "able",
267
- "ible",
268
- )
269
- incomplete_patterns = (
270
- "referen",
271
- "inte",
272
- "proces",
273
- "analy",
274
- "deve",
275
- "imple",
276
- "execu",
277
- )
278
-
279
- if any(last_word.lower().endswith(pattern) for pattern in incomplete_patterns):
280
- return True
281
- elif len(last_word) > 2 and not any(
282
- last_word.lower().endswith(end) for end in complete_endings
283
- ):
284
- # Likely truncated if doesn't end with common suffix
285
- return True
286
-
287
- # Check for unclosed markdown code blocks
288
- code_block_count = combined_content.count("```")
289
- if code_block_count % 2 != 0:
290
- return True
291
-
292
- # Check for unclosed brackets/parentheses (more opens than closes)
293
- open_brackets = (
294
- combined_content.count("[") + combined_content.count("(") + combined_content.count("{")
295
- )
296
- close_brackets = (
297
- combined_content.count("]") + combined_content.count(")") + combined_content.count("}")
298
- )
299
- if open_brackets > close_brackets:
300
- return True
301
-
302
- return False
303
-
304
-
305
234
  async def _display_raw_api_response(node: Any, ui: Any) -> None:
306
235
  """Display raw API response data when thoughts are enabled."""
307
236
 
@@ -382,6 +311,14 @@ async def _process_tool_calls(
382
311
  if tool_buffer is not None and part.tool_name in READ_ONLY_TOOLS:
383
312
  # Add to buffer instead of executing immediately
384
313
  tool_buffer.add(part, node)
314
+
315
+ # Update spinner to show we're collecting tools
316
+ buffered_count = len(tool_buffer.read_only_tasks)
317
+ await ui.update_spinner_message(
318
+ f"[bold #00d7ff]Collecting tools ({buffered_count} buffered)...[/bold #00d7ff]",
319
+ state_manager,
320
+ )
321
+
385
322
  if state_manager.session.show_thoughts:
386
323
  await ui.muted(
387
324
  f"⏸️ BUFFERED: {part.tool_name} (will execute in parallel batch)"
@@ -399,6 +336,13 @@ async def _process_tool_calls(
399
336
 
400
337
  start_time = time.time()
401
338
 
339
+ # Update spinner message for batch execution
340
+ tool_names = [part.tool_name for part, _ in buffered_tasks]
341
+ batch_msg = get_batch_description(len(buffered_tasks), tool_names)
342
+ await ui.update_spinner_message(
343
+ f"[bold #00d7ff]{batch_msg}...[/bold #00d7ff]", state_manager
344
+ )
345
+
402
346
  # Enhanced visual feedback for parallel execution
403
347
  await ui.muted("\n" + "=" * 60)
404
348
  await ui.muted(
@@ -452,9 +396,30 @@ async def _process_tool_calls(
452
396
  f"(~{speedup:.1f}x faster than sequential)\n"
453
397
  )
454
398
 
399
+ # Reset spinner message back to thinking
400
+ from tunacode.constants import UI_THINKING_MESSAGE
401
+
402
+ await ui.update_spinner_message(UI_THINKING_MESSAGE, state_manager)
403
+
455
404
  # Now execute the write/execute tool
456
405
  if state_manager.session.show_thoughts:
457
406
  await ui.warning(f"⚠️ SEQUENTIAL: {part.tool_name} (write/execute tool)")
407
+
408
+ # Update spinner for sequential tool
409
+ tool_args = getattr(part, "args", {}) if hasattr(part, "args") else {}
410
+ # Parse args if they're a JSON string
411
+ if isinstance(tool_args, str):
412
+ import json
413
+
414
+ try:
415
+ tool_args = json.loads(tool_args)
416
+ except (json.JSONDecodeError, TypeError):
417
+ tool_args = {}
418
+ tool_desc = get_tool_description(part.tool_name, tool_args)
419
+ await ui.update_spinner_message(
420
+ f"[bold #00d7ff]{tool_desc}...[/bold #00d7ff]", state_manager
421
+ )
422
+
458
423
  await tool_callback(part, node)
459
424
 
460
425
  # Track tool calls in session
@@ -0,0 +1,81 @@
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. Ends mid-word (no punctuation, space, or complete word)
19
+ # 3. Contains incomplete markdown/code blocks
20
+ # 4. Ends with incomplete parentheses/brackets
21
+
22
+ # Check for ellipsis at end suggesting truncation
23
+ if combined_content.endswith(("...", "…")) and not combined_content.endswith(("....", "….")):
24
+ return True
25
+
26
+ # Check for mid-word truncation (ends with letters but no punctuation)
27
+ if combined_content and combined_content[-1].isalpha():
28
+ # Look for incomplete words by checking if last "word" seems cut off
29
+ words = combined_content.split()
30
+ if words:
31
+ last_word = words[-1]
32
+ # Common complete word endings vs likely truncations
33
+ complete_endings = (
34
+ "ing",
35
+ "ed",
36
+ "ly",
37
+ "er",
38
+ "est",
39
+ "tion",
40
+ "ment",
41
+ "ness",
42
+ "ity",
43
+ "ous",
44
+ "ive",
45
+ "able",
46
+ "ible",
47
+ )
48
+ incomplete_patterns = (
49
+ "referen",
50
+ "inte",
51
+ "proces",
52
+ "analy",
53
+ "deve",
54
+ "imple",
55
+ "execu",
56
+ )
57
+
58
+ if any(last_word.lower().endswith(pattern) for pattern in incomplete_patterns):
59
+ return True
60
+ elif len(last_word) > 2 and not any(
61
+ last_word.lower().endswith(end) for end in complete_endings
62
+ ):
63
+ # Likely truncated if doesn't end with common suffix
64
+ return True
65
+
66
+ # Check for unclosed markdown code blocks
67
+ code_block_count = combined_content.count("```")
68
+ if code_block_count % 2 != 0:
69
+ return True
70
+
71
+ # Check for unclosed brackets/parentheses (more opens than closes)
72
+ open_brackets = (
73
+ combined_content.count("[") + combined_content.count("(") + combined_content.count("{")
74
+ )
75
+ close_brackets = (
76
+ combined_content.count("]") + combined_content.count(")") + combined_content.count("}")
77
+ )
78
+ if open_brackets > close_brackets:
79
+ return True
80
+
81
+ return False