Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
6ecbb74
fix: Improve robustness of Ctrl+C interruption
szmania Apr 15, 2026
7868d98
fix: Improve interrupt handling for LLM requests
szmania Apr 15, 2026
16af186
fix: Improve robustness of LLM request interruption
szmania Apr 15, 2026
241adef
fix: Make tool execution interruptible
szmania Apr 16, 2026
ecf67f2
fix: Improve robustness of Ctrl+C interruption during tool calls
szmania Apr 21, 2026
076b875
fix: Make LLM retries and tool execution interruptible
szmania Apr 25, 2026
602db8e
fix: Make LLM retries interruptible with Ctrl+C
szmania Apr 25, 2026
cae6a5b
fix: Make MCP server load/remove commands interruptible
szmania Apr 25, 2026
d4de14a
fix merge conflicts
szmania Apr 25, 2026
17b5ff5
fix: Correctly handle asyncio gather for interruptible tool execution
szmania Apr 26, 2026
16d0a41
fixed merge conflicts
Apr 28, 2026
9a9da53
fix: Improve interrupt handling for MCP tool calls
Apr 28, 2026
821717a
fix: Improve Ctrl+C interruption of MCP tool calls
Apr 29, 2026
4c436a3
cli-9: fix black
Apr 30, 2026
cca51d4
refactor: Improve interrupt handling with interruptible wrapper
Apr 30, 2026
f786255
fix: Remove KeyboardInterrupt handler from _run_linear
Apr 30, 2026
a76ae82
refactor: Use interruptible wrapper in _execute_mcp_tools
Apr 30, 2026
879fd8f
cli-9: used a coroutine
May 1, 2026
4ac2036
cli-9: used a coroutine
May 1, 2026
60e47d0
cli-9: fix formatting
May 1, 2026
07ba8bd
cli-9: fix formatting
May 1, 2026
9ef56e6
cli-9: fix formatting
May 1, 2026
54af2b6
cli-9: fix formatting
May 2, 2026
0c17f0a
cli-9: interruption fixes
May 2, 2026
5d1e4de
Bump Version
May 2, 2026
ade073c
If a tool takes no params, do not add it to invocation cache
May 2, 2026
cd9abe6
Update GetLines
May 3, 2026
14401e2
Interleave anchor and content bits instead of leaving anchor bits the…
May 3, 2026
7a72552
Rename ContextManager verbs so models don't mistakr "view" as a gener…
May 3, 2026
807eb15
Don't let multi file GetLines errantly report that all requests are u…
May 3, 2026
37bdff5
Strip hashlines of grep tool, rename `GetLines` to `ReadRange` so it'…
May 3, 2026
4045ca9
Add total cached token usage to total tokens section
May 3, 2026
8c7de4a
Improve cache efficiency of `ReadRange` tool by deferring when the co…
May 4, 2026
df6bce7
Fix bug where cache hits and writes can be None
May 4, 2026
5f3aff2
Fix tests
May 4, 2026
61c4348
Add `/exclude-skill` and `/include-skill` commands to control whether…
May 4, 2026
51c6ee0
Merge pull request #503 from szmania/cli-9-interruption-fixes
dwash96 May 4, 2026
dd59b1e
Remove extraneous new lines from Interrupt error message
May 4, 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.99.9.dev"
__version__ = "0.99.10.dev"
safe_version = __version__

try:
Expand Down
34 changes: 29 additions & 5 deletions cecli/coders/agent_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@

from .base_coder import Coder

from cecli.helpers.coroutines import interruptible # isort:skip


class AgentCoder(Coder):
"""Mode where the LLM autonomously manages which files are in context."""
Expand All @@ -42,6 +44,9 @@ class AgentCoder(Coder):
stop_on_empty = False

def __init__(self, *args, **kwargs):
if kwargs.get("uuid", None):
self.uuid = kwargs.get("uuid")

self.recently_removed = {}
self.tool_usage_history = []
self.loaded_custom_tools = []
Expand All @@ -55,7 +60,7 @@ def __init__(self, *args, **kwargs):
"commandinteractive",
"explorecode",
"ls",
"getlines",
"readrange",
"grep",
"thinking",
"updatetodolist",
Expand Down Expand Up @@ -301,8 +306,23 @@ async def _execute_local_tool_calls(self, tool_calls_list):
else:
all_results_content.append(f"Error: Unknown tool name '{tool_name}'")
if tasks:
task_results = await asyncio.gather(*tasks)
all_results_content.extend(str(res) for res in task_results)

async def gather_and_await():
return await asyncio.gather(*tasks, return_exceptions=True)

task_results, interrupted = await interruptible(
gather_and_await(), self.interrupt_event
)

if interrupted:
self.io.tool_warning("Tool execution interrupted.")
all_results_content.append("Tool execution interrupted by user.")
elif task_results:
for res in task_results:
if isinstance(res, Exception):
all_results_content.append(f"Error in tool execution: {res}")
else:
all_results_content.append(str(res))

if not await HookIntegration.call_post_tool_hooks(
self, tool_name, args_string, "\n\n".join(all_results_content)
Expand Down Expand Up @@ -393,7 +413,11 @@ async def _exec_async():
""")
return f"Error executing tool call {tool_name}: {e}"

return await _exec_async()
result, interrupted = await interruptible(_exec_async(), self.interrupt_event)

if interrupted:
return "Tool execution interrupted by user."
return result

def _calculate_context_block_tokens(self, force=False):
"""
Expand Down Expand Up @@ -995,7 +1019,7 @@ def _generate_tool_context(self, repetitive_tools):
context_parts.append("\n\n")
context_parts.append("## File Editing Tools Disabled")
context_parts.append(
"File editing tools are currently disabled.Use `GetLines` to determine the"
"File editing tools are currently disabled.Use `ReadRange` to determine the"
" current hashline prefixes needed to perform an edit and activate them when you"
" are ready to edit a file."
)
Expand Down
133 changes: 84 additions & 49 deletions cecli/coders/base_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,12 @@ class Coder:
partial_response_tool_calls = []
commit_before_message = []
message_cost = 0.0
total_tokens_sent = 0
total_tokens_received = 0
total_cached_tokens = 0
message_tokens_sent = 0
message_tokens_received = 0
message_cached_tokens = 0
add_cache_headers = False
cache_warming_thread = None
num_cache_warming_pings = 0
Expand Down Expand Up @@ -227,6 +233,7 @@ async def create(
ignore_mentions=from_coder.ignore_mentions,
total_tokens_sent=from_coder.total_tokens_sent,
total_tokens_received=from_coder.total_tokens_received,
total_cached_tokens=from_coder.total_cached_tokens,
file_watcher=from_coder.file_watcher,
mcp_manager=from_coder.mcp_manager,
uuid=from_coder.uuid,
Expand Down Expand Up @@ -316,6 +323,7 @@ def __init__(
ignore_mentions=None,
total_tokens_sent=0,
total_tokens_received=0,
total_cached_tokens=0,
file_watcher=None,
auto_copy_context=False,
auto_accept_architect=True,
Expand All @@ -331,6 +339,7 @@ def __init__(
):
# initialize from args.map_cache_dir
self.interrupt_event = asyncio.Event()
self.coroutines = coroutines
self.uuid = generate_unique_id()
if uuid:
self.uuid = uuid
Expand Down Expand Up @@ -388,8 +397,10 @@ def __init__(
self.total_cost = total_cost
self.total_tokens_sent = total_tokens_sent
self.total_tokens_received = total_tokens_received
self.total_cached_tokens = total_cached_tokens
self.message_tokens_sent = 0
self.message_tokens_received = 0
self.message_cached_tokens = 0

self.token_profiler = TokenProfiler(
enable_printing=nested.getter(self.args, "show_speed", False)
Expand Down Expand Up @@ -1370,11 +1381,6 @@ async def _run_parallel(self, with_message=None, preproc=True):
except (SwitchCoderSignal, SystemExit):
# Re-raise SwitchCoder to be handled by outer try block
raise
except KeyboardInterrupt:
# Handle keyboard interrupt gracefully
self.io.set_placeholder("")
self.io.stop_spinner()
self.keyboard_interrupt()
finally:
# Signal tasks to stop
self.input_running = False
Expand Down Expand Up @@ -1454,10 +1460,6 @@ async def input_task(self, preproc):

await asyncio.sleep(0.1) # Small yield to prevent tight loop

except KeyboardInterrupt:
self.io.set_placeholder("")
self.keyboard_interrupt()
await self.io.stop_task_streams()
except (SwitchCoderSignal, SystemExit):
raise
except Exception as e:
Expand Down Expand Up @@ -1738,8 +1740,7 @@ def keyboard_interrupt(self):
# Ensure cursor is visible on exit
Console().show_cursor(True)

self.io.tool_warning("\n\n^C KeyboardInterrupt")

self.io.tool_warning("^C KeyboardInterrupt")
self.interrupt_event.set()
self.last_keyboard_interrupt = time.time()

Expand Down Expand Up @@ -2262,9 +2263,16 @@ async def send_message(self, inp):
self.io.tool_error(err_msg)

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

_res, interrupted_sleep = await coroutines.interruptible(
asyncio.sleep(retry_delay), self.interrupt_event
)
if interrupted_sleep:
interrupted = True
break

continue
except KeyboardInterrupt:
except (KeyboardInterrupt, asyncio.CancelledError):
interrupted = True
break
except FinishReasonLength:
Expand Down Expand Up @@ -2629,11 +2637,19 @@ async def _execute_mcp_tools(self, server, tool_calls):
all_results_content.append("Tool Request Aborted.")
continue

call_result = await experimental_mcp_client.call_openai_tool(
session=session,
openai_tool=new_tool_call,
async def do_tool_call():
return await experimental_mcp_client.call_openai_tool(
session=session,
openai_tool=new_tool_call,
)

call_result, interrupted = await coroutines.interruptible(
do_tool_call(), self.interrupt_event
)

if interrupted:
raise KeyboardInterrupt("Tool call interrupted")

content_parts = []
if call_result.content:
for item in call_result.content:
Expand Down Expand Up @@ -2678,6 +2694,9 @@ async def _execute_mcp_tools(self, server, tool_calls):
}
)

except KeyboardInterrupt:
self.io.tool_warning(f"Tool call {tool_call.function.name} interrupted.")
raise
except Exception as e:
tool_error = f"Error executing tool call {tool_call.function.name}: \n{e}"
self.io.tool_warning(
Expand All @@ -2694,6 +2713,9 @@ async def _execute_mcp_tools(self, server, tool_calls):
tool_responses.append(
{"role": "tool", "tool_call_id": tool_call.id, "content": connection_error}
)
except asyncio.CancelledError:
# Re-raise CancelledError to ensure the task cancellation propagates
raise
except Exception as e:
connection_error = f"Could not connect to server {server.name}\n{e}"
self.io.tool_warning(connection_error)
Expand Down Expand Up @@ -2728,7 +2750,15 @@ async def process_tool_calls(self, tool_call_response):
return False

# 5. Execute tools
tool_responses_by_server = await self._execute_tool_groups(tool_groups)
self.interrupt_event.clear()

tool_responses_by_server, interrupted = await coroutines.interruptible(
self._execute_tool_groups(tool_groups), self.interrupt_event
)

if interrupted:
self.io.tool_warning("Tool execution interrupted.")
return False

# 6. Add responses to conversation (re-prefixing if necessary)
tool_responses = []
Expand Down Expand Up @@ -3040,33 +3070,22 @@ async def send(self, messages, model=None, functions=None, tools=None):
self.token_profiler.start()

try:
completion_task = asyncio.create_task(
model.send_completion(
messages,
functions,
self.stream,
self.temperature,
# This could include any tools, but for now it is just MCP tools
tools=tools,
override_kwargs=self.model_kwargs.copy(),
)
completion_coro = model.send_completion(
messages,
functions,
self.stream,
self.temperature,
# This could include any tools, but for now it is just MCP tools
tools=tools,
override_kwargs=self.model_kwargs.copy(),
interrupt_event=self.interrupt_event,
)
interrupt_task = asyncio.create_task(self.interrupt_event.wait())

done, pending = await asyncio.wait(
{completion_task, interrupt_task},
return_when=asyncio.FIRST_COMPLETED,
(hash_object, completion), interrupted = await coroutines.interruptible(
completion_coro, self.interrupt_event
)

if interrupt_task in done:
completion_task.cancel()
try:
await completion_task
except asyncio.CancelledError:
pass
if interrupted:
raise KeyboardInterrupt

hash_object, completion = completion_task.result()
self.chat_completion_call_hashes.append(hash_object.hexdigest())

if not isinstance(completion, ModelResponse):
Expand All @@ -3089,7 +3108,7 @@ async def send(self, messages, model=None, functions=None, tools=None):
self.token_profiler.on_error()
self.calculate_and_show_tokens_and_cost(messages, completion)
raise
except KeyboardInterrupt as kbi:
except (KeyboardInterrupt, asyncio.CancelledError) as kbi:
self.keyboard_interrupt()
raise kbi
finally:
Expand Down Expand Up @@ -3498,10 +3517,13 @@ def calculate_and_show_tokens_and_cost(self, messages, completion=None):
if completion and hasattr(completion, "usage") and completion.usage is not None:
prompt_tokens = completion.usage.prompt_tokens
completion_tokens = completion.usage.completion_tokens
cache_hit_tokens = getattr(completion.usage, "prompt_cache_hit_tokens", 0) or getattr(
completion.usage, "cache_read_input_tokens", 0
cache_hit_tokens = (
getattr(completion.usage, "prompt_cache_hit_tokens", 0)
or getattr(completion.usage, "cache_read_input_tokens", 0)
or 0
)
cache_write_tokens = getattr(completion.usage, "cache_creation_input_tokens", 0)
cache_write_tokens = getattr(completion.usage, "cache_creation_input_tokens", 0) or 0
self.message_cached_tokens += cache_hit_tokens

if hasattr(completion.usage, "cache_read_input_tokens") or hasattr(
completion.usage, "cache_creation_input_tokens"
Expand Down Expand Up @@ -3534,8 +3556,22 @@ def calculate_and_show_tokens_and_cost(self, messages, completion=None):
tokens_report, self.message_tokens_sent, self.message_tokens_received
)

total_combined_tokens = (
self.total_tokens_sent
+ self.total_tokens_received
+ self.message_tokens_sent
+ self.message_tokens_received
)
total_combined_cached = self.total_cached_tokens + self.message_cached_tokens

total_stats = f"{format_tokens(total_combined_tokens)}"
if total_combined_cached:
total_stats += f"/{format_tokens(total_combined_cached)}"

total_stats += " ↑↓"

if not self.get_active_model().info.get("input_cost_per_token"):
self.usage_report = tokens_report
self.usage_report = tokens_report + "\n" + total_stats
return

try:
Expand All @@ -3552,11 +3588,8 @@ def calculate_and_show_tokens_and_cost(self, messages, completion=None):
self.total_cost += cost
self.message_cost += cost

total_combined_tokens = (
self.total_tokens_sent + self.total_tokens_received + prompt_tokens + completion_tokens
)
cost_report = (
f"${self.format_cost(self.message_cost)} • {format_tokens(total_combined_tokens)} ↑↓"
f"${self.format_cost(self.message_cost)} • {total_stats}"
f" ${self.format_cost(self.total_cost)}"
)

Expand Down Expand Up @@ -3614,6 +3647,7 @@ def show_usage_report(self):

self.total_tokens_sent += self.message_tokens_sent
self.total_tokens_received += self.message_tokens_received
self.total_cached_tokens += self.message_cached_tokens

if self.tui and self.tui():
self.tui().update_cost(self.usage_report.replace("\n", " "))
Expand All @@ -3624,6 +3658,7 @@ def show_usage_report(self):
self.message_cost = 0.0
self.message_tokens_sent = 0
self.message_tokens_received = 0
self.message_cached_tokens = 0

def get_multi_response_content_in_progress(self, final=False):
cur = self.multi_response_content or ""
Expand Down
Loading
Loading