Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
c2d1377
feat: Add retry on empty LLM response
Jun 6, 2026
32b619b
Update tool validation to handle nested paths
Jun 7, 2026
f224593
#561: ReadRange string coercion
Jun 7, 2026
86a5d06
Update ReadRange to handle mixed input types
Jun 7, 2026
cf6fadc
Simplify grep tool, change defaults
Jun 7, 2026
d7f1834
Updates to hashpos system:
Jun 7, 2026
d52e51e
Update file diff messaging to remind the LLM to attend to the diff
Jun 7, 2026
993caff
Fix tests to account for CI/CD pipeline drives
Jun 7, 2026
2bce270
merged
Jun 7, 2026
05bb533
refactor: Move --retry-on-empty to retries config block
Jun 7, 2026
8655aa2
chore: Fix linter warnings
Jun 7, 2026
e763850
#564: Introduce global thread safe Event construct, ensure mcp server…
Jun 8, 2026
67bfa6f
Merge pull request #565 from szmania/cli-49-add-empty-llm-responses-t…
dwash96 Jun 8, 2026
2be4b1f
Set empty response default in send() not send_message()
Jun 8, 2026
014a529
Update reading and error messages
Jun 9, 2026
4582a50
Fix tests
Jun 9, 2026
1ad8d13
Don't double submit messages and don't include full method if the end…
Jun 9, 2026
69bbad5
Update pair reconciliation in read range
Jun 10, 2026
468a7f7
ReadRange description update
Jun 10, 2026
0d2b4b0
Update announcements section for style
Jun 11, 2026
822f162
Bump Version
Jun 11, 2026
f91518e
Update edit text description to get models to include demarcator expl…
Jun 11, 2026
b2b12bd
Forgive models for misusing EditText if they specify unique line content
Jun 11, 2026
e953727
EditText should explain where diffs are to LLMs
Jun 11, 2026
be2ee44
fix(tools): sanitize ExploreCode queries for Cymbal FTS5 safety
JessicaMulein Jun 11, 2026
4a7393c
ReadRange should describe how to do whole file reads
Jun 11, 2026
7f1d017
Merge pull request #570 from Digital-Defiance/pr/explore-code-cymbal-…
dwash96 Jun 11, 2026
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
2 changes: 1 addition & 1 deletion cecli/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from packaging import version

__version__ = "0.100.4.dev"
__version__ = "0.100.6.dev"
safe_version = __version__

try:
Expand Down
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
16 changes: 1 addition & 15 deletions cecli/coders/agent_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,20 +239,6 @@ def show_announcements(self):
if self.loaded_custom_tools:
self.io.tool_output(f"Loaded custom tools: {', '.join(self.loaded_custom_tools)}")

skills = self.skills_manager.find_skills()
if skills:
skills_list = []
for skill in skills:
skills_list.append(skill.name)
joined_skills = ", ".join(skills_list)
self.io.tool_output(f"Available Skills: {joined_skills}")

registry = AgentService.get_registry()
if registry:
names = sorted(registry.keys())
joined_names = ", ".join(names)
self.io.tool_output(f"Available Subagents: {joined_names}")

def get_local_tool_schemas(self):
"""Returns the JSON schemas for all local tools using the tool registry."""
schemas = []
Expand Down Expand Up @@ -1115,7 +1101,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
210 changes: 157 additions & 53 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 @@ -773,91 +778,118 @@ def cur_messages(self):
"""Get CUR messages from ConversationManager."""
return ConversationService.get_manager(self).get_messages_dict(MessageTag.CUR)

@staticmethod
def _strip_provider(model_name: str) -> str:
"""Remove provider prefix from model name (e.g., 'openai/gpt-4' -> 'gpt-4')."""
if "/" in model_name:
return model_name.split("/", 1)[1]
return model_name

def get_announcements(self):
lines = []
lines.append(f"cecli v{__version__}")
sections = {}

# Model
# --- MODELS ---
main_model = self.main_model
weak_model = main_model.weak_model

models_items = [f"{self._strip_provider(main_model.name)} (main)"]
agent_model = main_model.agent_model
weak_model = main_model.weak_model

if weak_model is not main_model:
prefix = "Main model"
else:
prefix = "Model"
if agent_model and agent_model.name != main_model.name:
models_items.append(f"{self._strip_provider(agent_model.name)} (agent)")

if weak_model and weak_model.name != main_model.name:
models_items.append(f"{self._strip_provider(weak_model.name)} (weak)")
if self.edit_format == "architect":
models_items.append(f"{self._strip_provider(main_model.editor_model.name)} (editor)")

sections["Models"] = {"items": models_items}

output = f"{prefix}: {main_model.name} with {self.edit_format} edit format"
# --- SETTINGS ---
settings_items = []

# Check for thinking token budget
# Edit format
settings_items.append(f"{self.edit_format} (edit format)")

# Thinking tokens
thinking_tokens = main_model.get_thinking_tokens()
if thinking_tokens:
output += f", {thinking_tokens} think tokens"
settings_items.append(f"{thinking_tokens} think tokens")

# Check for reasoning effort
# Reasoning effort
reasoning_effort = main_model.get_reasoning_effort()
if reasoning_effort:
output += f", reasoning {reasoning_effort}"
settings_items.append(f"reasoning {reasoning_effort}")

# Prompt cache
if self.add_cache_headers or main_model.caches_by_default:
output += ", prompt cache"
if main_model.info.get("supports_assistant_prefill"):
output += ", infinite output"
if self.copy_paste_mode:
output += ", copy/paste mode"
settings_items.append("prompt cache")

lines.append(output)
# Infinite output
if main_model.info.get("supports_assistant_prefill"):
settings_items.append("infinite output")

if self.edit_format == "architect":
output = (
f"Editor model: {main_model.editor_model.name} with"
f" {main_model.editor_edit_format} edit format"
)
lines.append(output)
# Copy/paste mode
if self.copy_paste_mode:
settings_items.append("copy/paste mode")

if weak_model is not main_model:
output = f"Weak model: {weak_model.name}"
lines.append(output)
if settings_items:
sections["Settings"] = {"items": settings_items}

if agent_model is not main_model:
output = f"Agent model: {agent_model.name}"
lines.append(output)
# --- ENVIRONMENT ---
env_items = []
repo_map_tokens = None # Track for later warning check

# Repo
if self.repo:
rel_repo_dir = self.repo.get_rel_repo_dir()
num_files = len(self.repo.get_tracked_files())

lines.append(f"Git repo: {rel_repo_dir} with {num_files:,} files")
env_items.append(f"{rel_repo_dir} ({num_files:,} files)")
if num_files > 1000:
lines.append(
env_items.append(
"Warning: For large repos, consider using --subtree-only and .cecli_ignore"
)
lines.append(f"See: {urls.large_repos}")
env_items.append(f"See: {urls.large_repos}")
else:
lines.append("Git repo: none")
env_items.append("no git repo")

# Repo-map
if self.repo_map:
map_tokens = self.repo_map.max_map_tokens
if map_tokens > 0:
refresh = self.repo_map.refresh
lines.append(f"Repo-map: using {map_tokens} tokens, {refresh} refresh")
max_map_tokens = self.get_active_model().get_repo_map_tokens() * 2
if map_tokens > max_map_tokens:
lines.append(
f"Warning: map-tokens > {max_map_tokens} is not recommended. Too much"
" irrelevant code can confuse LLMs."
)
env_items.append(f"map ({map_tokens} tokens, {refresh} refresh)")
repo_map_tokens = map_tokens
else:
lines.append("Repo-map: disabled because map_tokens == 0")
env_items.append("repo-map disabled")
else:
lines.append("Repo-map: disabled")
env_items.append("repo-map disabled")

sections["Environment"] = {"items": env_items}
# --- CAPABILITIES ---
capabilities = {}

# Sub-agents
try:
from cecli.helpers.agents.service import AgentService

registry = AgentService.get_registry()
if registry:
capabilities["Subagents"] = sorted(registry.keys())
except Exception:
pass

# Skills
if hasattr(self, "skills_manager") and self.skills_manager:
try:
skills = self.skills_manager.find_skills()
if skills:
capabilities["Skills"] = [s.name for s in skills]
except Exception:
pass

# MCP Servers
if self.mcp_tools:
mcp_servers = []
for server_name, server_tools in self.mcp_tools:
# Filter servers per instance configuration
if (
self.registered_servers["included"]
and server_name not in self.registered_servers["included"]
Expand All @@ -866,17 +898,49 @@ def get_announcements(self):
if server_name in self.registered_servers["excluded"]:
continue
mcp_servers.append(server_name)

if mcp_servers:
lines.append(f"MCP servers configured: {', '.join(mcp_servers)}")
capabilities["Servers"] = mcp_servers

if capabilities:
# sections["Extensions"] = {"subsections": capabilities}
sections["Environment"]["subsections"] = capabilities

# --- RENDER ---
lines = []

# Version line (CLI only; TUI has its own banner)
if not self.args.tui:
lines.append(f"cecli v{__version__}")

for name, section in sections.items():
if "items" in section:
lines.append(f"{name:15s}" + " • ".join(section["items"]))
if "subsections" in section:
last_key = next(reversed(section["subsections"]))
# lines.append(name)
for sub_name, sub_items in section["subsections"].items():
connector = "└─" if sub_name == last_key else "├─"
lines.append(f" {connector} {sub_name:10} {' • '.join(sub_items)}")

# Repo-map max_tokens warning
if repo_map_tokens is not None:
max_map_tokens = self.get_active_model().get_repo_map_tokens() * 2
if repo_map_tokens > max_map_tokens:
lines.append(
f"Warning: map-tokens > {max_map_tokens} is not recommended. Too much"
" irrelevant code can confuse LLMs."
)

# Read-only stubs
for fname in self.abs_read_only_stubs_fnames:
rel_fname = self.get_rel_fname(fname)
lines.append(f"Added {rel_fname} to the chat (read-only stub).")

# Restored conversation
if ConversationService.get_manager(self).get_messages_dict(MessageTag.DONE):
lines.append("Restored previous conversation history.")

# Multiline mode
if self.io.multiline_mode and not self.args.tui:
lines.append("Multiline mode: Enabled. Enter inserts newline, Alt-Enter submits text")

Expand Down Expand Up @@ -1643,6 +1707,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 +2467,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 +3350,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 +3401,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 +3484,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 +3642,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
Loading
Loading