Skip to content
Merged
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
20 changes: 19 additions & 1 deletion cecli/args.py
Original file line number Diff line number Diff line change
Expand Up @@ -449,9 +449,17 @@ def get_parser(default_config_files, git_root):
group.add_argument(
"--enable-context-compaction",
action=argparse.BooleanOptionalAction,
default=True,
default=False,
help="Enable automatic compaction of chat history to conserve tokens (default: False)",
)
group.add_argument(
"--max-compaction-retries",
type=int,
default=3,
metavar="N",
choices=range(1, 11),
help="Maximum number of compactions per request before propagating the error (default: 3, min: 1, max: 10)",
)
group.add_argument(
"--context-compaction-max-tokens",
type=float,
Expand All @@ -467,6 +475,16 @@ def get_parser(default_config_files, git_root):
default=4096,
help="The target maximum number of tokens for the generated summary. (default: 4096)",
)
group.add_argument(
"--ignore-context-limit",
action=argparse.BooleanOptionalAction,
default=False,
help=(
"Ignore the model's context window limit when sending requests."
" WARNING: This may cause API errors or truncated responses."
" Not settable via env var alone in agent mode (default: False)"
),
)

##########
group = parser.add_argument_group("Cache settings")
Expand Down
132 changes: 81 additions & 51 deletions cecli/coders/base_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,7 @@ def __init__(
auto_accept_architect=True,
mcp_manager=None,
enable_context_compaction=False,
max_compaction_retries=3,
context_compaction_max_tokens=None,
context_compaction_summary_tokens=8192,
map_cache_dir=".",
Expand Down Expand Up @@ -477,6 +478,11 @@ def __init__(
self.context_compaction_current_ratio = 0
self.context_compaction_max_tokens = context_compaction_max_tokens
self.context_compaction_summary_tokens = context_compaction_summary_tokens
self.max_compaction_retries = max_compaction_retries

# Token floor guard: prevents infinite compaction on near-empty context
self._compaction_floor_reached = False

self.max_reflections = nested.getter(self.args, "max_reflections", 3)

if not fnames:
Expand Down Expand Up @@ -1728,36 +1734,6 @@ async def generate(self, user_message, preproc):
self.interrupt_event.clear()

try:
if self.enable_context_compaction:
# Skip compaction if the user wants to clear or exit
# Compacting is wasteful since /clear will clear everything
# and /exit will exit the application
stripped = user_message.strip()
is_command = self.commands.is_command(stripped)
is_allowed_command = False

if is_command:
res = self.commands.matching_commands(user_message)
if res is not None:
matching_commands, first_word, rest_inp = res
if len(matching_commands) == 1:
command = matching_commands[0]
splits = (rest_inp or "").split()
split_map = {
"/agent": 1,
"/architect": 1,
"/ask": 1,
"/code": 1,
"/model": 2,
}
if command in split_map and len(splits) >= split_map.get(command, 0):
is_allowed_command = True

if not is_command or is_allowed_command:
self.compact_context_completed = False
await self.compact_context_if_needed()
self.compact_context_completed = True

self.run_one_completed = False
await self.run_one(user_message, preproc)
self.show_undo_hint()
Expand Down Expand Up @@ -1897,9 +1873,6 @@ async def run_one(self, user_message, preproc):
await self.auto_save_session(force=True)
break

if self.enable_context_compaction:
await self.compact_context_if_needed()

if nested.getter(self, "agent_finished", False):
await self.auto_save_session(force=True)
break
Expand Down Expand Up @@ -1986,10 +1959,14 @@ def keyboard_interrupt(self):

# Old summarization system removed - using context compaction logic instead

async def compact_context_if_needed(self, force=False, message=""):
async def compact_context_if_needed(self, force=False, message="", retry_index=0):
if not self.enable_context_compaction:
return

# Skip compaction if token floor has been reached (context too small to compact further)
if self._compaction_floor_reached:
return

# Trigger background observation/reflection check
await ObservationService.get_instance(self).check_and_trigger()

Expand All @@ -2007,6 +1984,25 @@ async def compact_context_if_needed(self, force=False, message=""):
file_context_tokens = self.summarizer.count_tokens(file_context_messages)
all_tokens = self.summarizer.count_tokens(all_messages)

# Determine if compaction is worthwhile
compactable_tokens = done_tokens + cur_tokens + diff_tokens

# Condition 1: Percentage check (is chat history a significant part of the context?)
is_worth_by_percentage = all_tokens > 0 and (compactable_tokens / all_tokens) >= 0.20

# Condition 2: Absolute savings check (would compacting save enough tokens to fit?)
tokens_over_limit = all_tokens - (self.context_compaction_max_tokens or all_tokens)
potential_savings = compactable_tokens * 0.90
is_worth_by_absolute_savings = (
tokens_over_limit > 0 and potential_savings >= tokens_over_limit
)

if not (is_worth_by_percentage or is_worth_by_absolute_savings):
self.io.tool_output(
"Skipping compaction: Not enough chat history to make a difference. Use /drop to remove files."
)
return

message_tokens = done_tokens + cur_tokens
file_tokens = diff_tokens + file_context_tokens
combined_tokens = done_tokens + cur_tokens + diff_tokens + file_context_tokens
Expand Down Expand Up @@ -2123,7 +2119,27 @@ async def summarize_and_update(messages, tag):
if cur_tokens > self.context_compaction_max_tokens or cur_tokens > done_tokens:
await summarize_and_update(cur_messages, MessageTag.CUR)

self.io.tool_output("...chat history compacted.")
self.io.update_spinner(self.io.last_spinner_text)

# Post-compaction token floor check
# Recalculate tokens after compaction to prevent infinite loops
# on already-minimal context
all_messages = manager.get_messages_dict()
post_compaction_tokens = self.summarizer.count_tokens(all_messages)
token_floor = (
self.context_compaction_max_tokens * 0.25
if self.context_compaction_max_tokens
else 0
)

if post_compaction_tokens < token_floor:
self._compaction_floor_reached = True
self.io.tool_output(
"...context is already at minimum size, cannot compact further."
)
self.io.update_spinner(self.io.last_spinner_text)
return False

self.io.update_spinner(self.io.last_spinner_text)

manager.clear_tag(MessageTag.DIFFS)
Expand Down Expand Up @@ -2423,22 +2439,34 @@ async def check_tokens(self, messages):
max_input_tokens = self.get_active_model().info.get("max_input_tokens") or 0

if max_input_tokens and input_tokens >= max_input_tokens:
self.io.tool_error(
f"Your estimated chat context of {input_tokens:,} tokens exceeds the"
f" {max_input_tokens:,} token limit for {self.get_active_model().name}!"
)
self.io.tool_output("To reduce the chat context:")
self.io.tool_output("- Use /drop to remove unneeded files from the chat")
self.io.tool_output("- Use /clear to clear the chat history")
self.io.tool_output("- Break your code into smaller files")
self.io.tool_output(
"It's probably safe to try and send the request, most providers won't charge if"
" the context limit is exceeded."
)
if self.enable_context_compaction:
self.io.tool_output(
f"Estimated chat context of {input_tokens:,} tokens exceeds the"
f" {max_input_tokens:,} token limit. Attempting to compact..."
)
await self.compact_context_if_needed(force=True)

if not await self.io.confirm_ask("Try to proceed anyway?"):
return False
return True
# After compaction, re-format messages and re-check tokens
messages = self.format_messages()
input_tokens = self.get_active_model().token_count(messages)

if max_input_tokens and input_tokens >= max_input_tokens:
self.io.tool_error(
f"Your estimated chat context of {input_tokens:,} tokens still exceeds the"
f" {max_input_tokens:,} token limit for {self.get_active_model().name}!"
)
self.io.tool_output("To reduce the chat context:")
self.io.tool_output("- Use /drop to remove unneeded files from the chat")
self.io.tool_output("- Use /clear to clear the chat history")
self.io.tool_output("- Break your code into smaller files")
self.io.tool_output(
"It's probably safe to try and send the request, most providers won't charge if"
" the context limit is exceeded."
)

if not await self.io.confirm_ask("Try to proceed anyway?"):
return None
return messages

def get_active_model(self):
return self.main_model
Expand Down Expand Up @@ -2499,7 +2527,8 @@ async def format_in_executor():

messages = result

if not await self.check_tokens(messages):
messages = await self.check_tokens(messages)
if not messages:
return

if self.verbose:
Expand Down Expand Up @@ -3458,6 +3487,7 @@ async def send(self, messages, model=None, functions=None, tools=None):
tools=tools,
override_kwargs=self.model_kwargs.copy(),
interrupt_event=self.interrupt_event,
coder=self,
)

try:
Expand Down
18 changes: 16 additions & 2 deletions cecli/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,12 @@ def _load(self, strict=False):
def exceptions_tuple(self):
return tuple(self.exceptions)

def get_ex_info(self, ex):
"""Return the ExInfo for a given exception instance"""
def get_ex_info(self, ex, enable_context_compaction=False):
"""Return the ExInfo for a given exception instance.

When enable_context_compaction is True, ContextWindowExceededError
is treated as retryable so the caller can compact and retry.
"""
import litellm

if ex.__class__ is litellm.APIConnectionError:
Expand All @@ -102,6 +106,16 @@ def get_ex_info(self, ex):
),
)

# ContextWindowExceededError: retryable only when compaction is enabled
if ex.__class__ is litellm.ContextWindowExceededError:
if enable_context_compaction:
return ExInfo(
"ContextWindowExceededError",
True,
"The context window has been exceeded. Compacting and retrying.",
)
return ExInfo("ContextWindowExceededError", False, None)

# Check for specific non-retryable APIError cases like insufficient credits
if ex.__class__ is litellm.APIError:
err_str = str(ex).lower()
Expand Down
14 changes: 14 additions & 0 deletions cecli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -610,6 +610,20 @@ async def main_async(

set_args_error_data(args)

# Security: In agent mode, --ignore-context-limit must not be settable via
# environment variable alone. Reject if env var is set but CLI flag is absent.
is_agent_mode = args.edit_format == "agent" or getattr(args, "agent", False)
if is_agent_mode and args.ignore_context_limit:
env_val = os.environ.get("CECLI_IGNORE_CONTEXT_LIMIT", "").lower()
if env_val in ("true", "1", "yes"):
import logging

logging.getLogger("cecli").warning(
"--ignore-context-limit via env var rejected in agent mode."
" Use --ignore-context-limit CLI flag instead."
)
args.ignore_context_limit = False

if len(unknown):
print("Unknown Args: ", unknown)

Expand Down
4 changes: 3 additions & 1 deletion cecli/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,9 @@ async def connect(self):

command = self.config["command"]

env = {**os.environ, **self.config["env"]} if self.config.get("env") else None
env = os.environ.copy()
if self.config.get("env"):
env.update(self.config["env"])

server_params = StdioServerParameters(
command=command,
Expand Down
Loading
Loading