Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion cecli/args.py
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,10 @@ def get_parser(default_config_files, git_root):
group.add_argument(
"--retries",
metavar="RETRIES_JSON",
help="Specify LLM retry configuration as a JSON string",
help=(
'Specify LLM retry configuration as a JSON/YAML string (e.g., \'{"retry_on_empty": '
"true}')"
),
default=None,
)

Expand Down
10 changes: 10 additions & 0 deletions cecli/args_formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,16 @@ def _format_action(self, action):
break
switch = switch.lstrip("-")

if switch == "retries":
parts.append(f"## {action.help}")
parts.append("#retries:")
parts.append("# retry-timeout: 60")
parts.append("# retry-backoff-factor: 2.0")
parts.append("# retry-on-unavailable: true")
parts.append("# retry-on-empty: false")
parts.append("")
return "\n".join(parts)

if isinstance(action, argparse._StoreTrueAction):
default = False
elif isinstance(action, argparse._StoreConstAction):
Expand Down
2 changes: 1 addition & 1 deletion cecli/coders/agent_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -1115,7 +1115,7 @@ def _generate_tool_context(self, repetitive_tools):
context_parts.append("## File Editing Tools Disabled")
context_parts.append(
"File editing tools are currently disabled. Use `ReadRange` to determine the"
" current content hash prefixes needed to perform an edit and activate them when"
" current content ID prefixes needed to perform an edit and activate them when"
" you are ready to edit a file."
)

Expand Down
51 changes: 48 additions & 3 deletions cecli/coders/base_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
from cecli.helpers.io_proxy import IOProxy
from cecli.helpers.observations.service import ObservationService
from cecli.helpers.profiler import TokenProfiler
from cecli.helpers.threading import ThreadSafeEvent
from cecli.history import ChatSummary
from cecli.hooks import HookIntegration
from cecli.io import ConfirmGroup, InputOutput
Expand Down Expand Up @@ -91,6 +92,10 @@ class FinishReasonLength(Exception):
pass


class EmptyResponseError(Exception):
pass


def wrap_fence(name):
return f"<{name}>", f"</{name}>"

Expand Down Expand Up @@ -420,7 +425,7 @@ def __init__(
# Each contains "included" and "excluded" sets that filter from the global singletons
self.registered_tools = {"included": set(), "excluded": set()}
self.registered_servers = {"included": set(), "excluded": set()}
self.interrupt_event = asyncio.Event()
self.interrupt_event = ThreadSafeEvent()
self.uuid = str(generate_unique_id())

if uuid:
Expand Down Expand Up @@ -1643,6 +1648,7 @@ async def output_task(self, preproc):

async def generate(self, user_message, preproc):
await asyncio.sleep(0.1)
self.interrupt_event.clear()

try:
if self.enable_context_compaction:
Expand Down Expand Up @@ -2402,6 +2408,39 @@ async def format_in_executor():
async for chunk in self.send(messages, tools=self.get_tool_list()):
yield chunk
break
except EmptyResponseError:
self.io.tool_warning(self.empty_llm_tool_warning())

retry_on_empty = False
retries_config = self.get_active_model().retries
if isinstance(retries_config, str):
try:
retries_config = json.loads(retries_config)
except json.JSONDecodeError:
self.io.tool_warning(
f"Could not parse retries config: {retries_config}"
)
retries_config = {}
if isinstance(retries_config, dict):
retry_on_empty = retries_config.get("retry_on_empty", False)

if not retry_on_empty:
break

retry_delay *= 2
if retry_delay > RETRY_TIMEOUT:
self.io.tool_error("Retry timeout exceeded on empty response.")
break

self.io.tool_output(f"Retrying in {retry_delay:.1f} seconds...")

_res, interrupted_sleep = await coroutines.interruptible(
asyncio.sleep(retry_delay), self.interrupt_event
)
if interrupted_sleep:
interrupted = True
break
continue
except litellm_ex.exceptions_tuple() as err:
ex_info = litellm_ex.get_ex_info(err)

Expand Down Expand Up @@ -3252,6 +3291,7 @@ async def send(self, messages, model=None, functions=None, tools=None):
self.interrupt_event.clear()
self.got_reasoning_content = False
self.ended_reasoning_content = False
self.empty_response = False

self._streaming_buffer_length = 0
self.io.reset_streaming_response()
Expand Down Expand Up @@ -3302,6 +3342,9 @@ async def send(self, messages, model=None, functions=None, tools=None):
else:
await self.show_send_output(completion)

if self.empty_response:
raise EmptyResponseError

response, func_err, content_err = self.consolidate_chunks()

if response:
Expand Down Expand Up @@ -3382,7 +3425,8 @@ async def show_send_output(self, completion):
and not len(self.partial_response_tool_calls)
and not len(self.partial_response_reasoning_content)
):
self.io.tool_warning(self.empty_llm_tool_warning())
self.empty_response = True
return

self.io.assistant_output(show_resp, pretty=self.show_pretty())

Expand Down Expand Up @@ -3539,7 +3583,8 @@ async def show_send_output_stream(self, completion):
return

if not received_content and len(self.partial_response_tool_calls) == 0:
self.io.tool_warning(self.empty_llm_tool_warning())
self.empty_response = True
return

def consolidate_chunks(self):
if self.partial_response_consolidated:
Expand Down
4 changes: 2 additions & 2 deletions cecli/commands/core.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import asyncio
import json
import re
import sys
Expand All @@ -7,6 +6,7 @@
from cecli.commands.utils.registry import CommandRegistry
from cecli.helpers import nested, plugin_manager
from cecli.helpers.file_searcher import handle_core_files
from cecli.helpers.threading import ThreadSafeEvent
from cecli.repo import ANY_GIT_ERROR


Expand Down Expand Up @@ -94,7 +94,7 @@ def __init__(
self.custom_commands = nested.getter(customizations, "command-paths", [])
self._load_custom_commands(self.custom_commands)

self.cmd_running_event = asyncio.Event()
self.cmd_running_event = ThreadSafeEvent()
self.cmd_running_event.set()
self.last_command_show_notification = True

Expand Down
9 changes: 6 additions & 3 deletions cecli/helpers/conversation/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -281,14 +281,17 @@ def update_file_diff(self, fname: str) -> Optional[str]:
diff_message = {
"role": "user",
"content": (
f"{rel_fname} has been updated. Here is a git diff of the changes to"
f" review:\n\n{diff}"
f"{rel_fname} has been updated. Review this git diff of the changes to"
f" ensure the modifications are intended:\n\n{diff}"
),
}

assistant_msg = {
"role": "assistant",
"content": f"Thank you for sharing this diff of the updates to {rel_fname}.",
"content": (
f"Thank you for sharing this diff of the updates to {rel_fname}."
" I will review their contents next turn."
),
}

ConversationService.get_manager(coder).add_message(
Expand Down
2 changes: 1 addition & 1 deletion cecli/helpers/conversation/integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -842,7 +842,7 @@ def add_file_context_messages(self, promote_messages=True) -> None:

user_msg = {
"role": "user",
"content": f"Hash-Prefixed Context For:\n{rel_fname}\n\n{context_content}",
"content": f"ID-Prefixed Context For:\n{rel_fname}\n\n{context_content}",
}

assistant_msg = {
Expand Down
4 changes: 3 additions & 1 deletion cecli/helpers/coroutines.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import asyncio

from cecli.helpers.threading import ThreadSafeEvent


async def interruptible_async_generator(async_generator, interrupt_event):
"""
Expand Down Expand Up @@ -57,7 +59,7 @@ async def interruptible(coroutine, interrupt_event):
- If interrupted: (None, True)
"""
if interrupt_event is None:
interrupt_event = asyncio.Event()
interrupt_event = ThreadSafeEvent()

main_task = asyncio.create_task(coroutine)
interrupt_task = asyncio.create_task(interrupt_event.wait())
Expand Down
135 changes: 26 additions & 109 deletions cecli/helpers/hashline.py
Original file line number Diff line number Diff line change
Expand Up @@ -650,6 +650,20 @@ def _apply_start_stitching(
# The replacement line matches the line being replaced
# Don't stitch to a line in lines_before_range
continue

# Require 2 consecutive matching lines to avoid false positives
# (single boilerplate lines like "import sys" or "def foo():"
# are too likely to be coincidental)
if line_idx + 1 < len(replacement_lines) and match_index + 1 < len(
lines_before_range_normalized
):
next_repl = replacement_lines[line_idx + 1]
next_repl_stripped = strip_hashline(next_repl)
if not next_repl_stripped.endswith("\n"):
next_repl_stripped += "\n"
if next_repl_stripped != lines_before_range_normalized[match_index + 1]:
continue # Only 1 line matches — likely coincidental

# Found a line that already exists before the range!
# This is a non-contiguous match - we need to "stitch" the replacement
# at this exact content match to prevent duplicate code structures
Expand Down Expand Up @@ -694,9 +708,9 @@ def _apply_start_stitching(
start_idx = new_start_idx
replacement_lines = new_replacement_lines
else:
# Can't extend backward due to overlap, but we can still truncate
# the replacement text to avoid duplication
replacement_lines = new_replacement_lines
# Can't extend backward due to overlap with another operation
# Don't truncate without extending — that would silently lose content
continue # Try next line instead

# We've found our stitching point, break out of the loop
break
Expand Down Expand Up @@ -772,6 +786,15 @@ def _apply_end_stitching(
# Check if this line exists anywhere in lines_after_range_normalized
try:
match_index = lines_after_range_normalized.index(replacement_line_stripped)

# Require 2 consecutive matching lines to reduce false positives
if line_idx - 1 >= 0 and match_index - 1 >= 0:
prev_repl = replacement_lines[line_idx - 1]
prev_repl_stripped = strip_hashline(prev_repl)
if not prev_repl_stripped.endswith("\n"):
prev_repl_stripped += "\n"
if prev_repl_stripped != lines_after_range_normalized[match_index - 1]:
continue # Only 1 line matches — likely coincidental
# Found a line that already exists after the range!
# This is a non-contiguous match - we need to "stitch" the replacement
# at this exact content match to prevent duplicate code structures
Expand Down Expand Up @@ -900,109 +923,6 @@ def _apply_range_shifting(hashed_lines, resolved_ops):
return resolved_ops


# Regex configuration
RE_CODE_NOISE = r'(#.*|//.*|/\*[\s\S]*?\*/|"(?:\\.|[^"\\])*"|\'(?:\\.|[^\'\\])*\')'


def get_brace_balance(lines_to_check: list[str]) -> int:
"""
Calculates the net curly brace debt of a list of lines.
Automatically strips hashlines, comments, and string literals.
"""
text = "".join(lines_to_check)
clean_code = strip_hashline(text)
clean_code = re.sub(RE_CODE_NOISE, "", clean_code)
return clean_code.count("{") - clean_code.count("}")


def _apply_closure_safeguard(hashed_lines, resolved_ops):
"""
Enhanced closure safeguard with dynamic bidirectional search.
"""
# Tune these to adjust how far the 'healing' logic searches
MAX_LOOK_DOWN = 5
# Note: We'll calculate the actual MAX_LOOK_UP per operation
# to ensure we don't scan past the start_idx.

for i, resolved in enumerate(resolved_ops):
op = resolved["op"]
if op["operation"] not in {"replace", "delete"}:
continue

replacement_text = op.get("text", "") or ""
replacement_lines = replacement_text.splitlines(keepends=True)

# --- PHASE 1: BIDIRECTIONAL STRUCTURAL HEALING ---
if get_brace_balance([replacement_text]) == 0:
start_idx = resolved["start_idx"]
orig_end_idx = resolved["end_idx"]

if get_brace_balance(hashed_lines[start_idx : orig_end_idx + 1]) != 0:
# Dynamic Search List Generation
# We limit look-up so we don't scan before the start_idx
actual_max_up = orig_end_idx - start_idx
actual_max_down = max(MAX_LOOK_DOWN, orig_end_idx - start_idx)
search_offsets = []

# Generate alternating offsets: [1, -1, 2, -2, ... N]
for dist in range(1, max(actual_max_down, actual_max_up) + 1):
if dist <= actual_max_down:
search_offsets.append(dist)
if dist <= actual_max_up:
search_offsets.append(-dist)

for offset in search_offsets:
candidate_end = orig_end_idx + offset

# Safety: check bounds and avoid overlapping other ops
if candidate_end < start_idx or candidate_end >= len(hashed_lines):
continue

if any(
j != i and (other["start_idx"] <= candidate_end <= other["end_idx"])
for j, other in enumerate(resolved_ops)
):
continue

if get_brace_balance(hashed_lines[start_idx : candidate_end + 1]) == 0:
resolved["end_idx"] = candidate_end
break

# --- PHASE 2: CONTRACTION (Indentation Guard) ---
# Prevents replacing an outer-scope brace if the replacement text already
# includes its own correctly indented closer.
if not replacement_lines:
continue

last_repl_line = strip_hashline(replacement_lines[-1])
last_repl_stripped = last_repl_line.strip().rstrip(";,")

if last_repl_stripped and last_repl_stripped[-1] in "})]":
# Calculate replacement indent
repl_indent = len(last_repl_line) - len(last_repl_line.lstrip(" \t"))

if resolved["end_idx"] < len(hashed_lines):
end_line = strip_hashline(hashed_lines[resolved["end_idx"]])
check_end = end_line.strip().rstrip(";,")

if check_end and check_end[-1] in "})]":
# Calculate indent of the existing brace in the file
file_indent = len(end_line) - len(end_line.lstrip(" \t"))

# If the file's brace is less indented, it belongs to an outer scope
if file_indent < repl_indent and resolved["end_idx"] > resolved["start_idx"]:
new_end_idx = resolved["end_idx"] - 1

# Safety: don't contract into another operation's territory
if not any(
j != i and (other["start_idx"] <= new_end_idx <= other["end_idx"])
for j, other in enumerate(resolved_ops)
):
resolved["end_idx"] = new_end_idx

return resolved_ops


def _merge_replace_operations(resolved_ops):
"""
Merge contiguous or overlapping replace operations.
Expand Down Expand Up @@ -1411,9 +1331,6 @@ def apply_hashline_operations(
resolved_ops = _merge_replace_operations(resolved_ops)
# Apply content-aware range expansion/shifting for replace operations
# resolved_ops = _apply_range_shifting(hashed_lines, resolved_ops)
# Apply closure safeguard for braces/brackets
resolved_ops = _apply_closure_safeguard(hashed_lines, resolved_ops)

# Sort by start_idx descending to apply from bottom to top
# When operations have same start_idx, apply in order: insert, replace, delete
# This ensures correct behavior when multiple operations target the same line
Expand Down
Loading
Loading