Skip to content

Commit a383202

Browse files
authored
Merge pull request #516 from szmania/cli-22-robust-interruption
refactor: Make agent mode interruptions more robust
2 parents af6b4b4 + b10a04f commit a383202

7 files changed

Lines changed: 302 additions & 150 deletions

File tree

cecli/coders/agent_coder.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -795,7 +795,13 @@ async def gather_and_await():
795795

796796
if self.auto_lint and used_write_tool:
797797
edited = list(self.files_edited_by_tools)
798-
lint_errors = self.lint_edited(edited, show_output=False)
798+
lint_coro = self.lint_edited(edited, show_output=False)
799+
lint_errors, interrupted = await self.coroutines.interruptible(
800+
lint_coro, self.interrupt_event
801+
)
802+
if interrupted:
803+
raise KeyboardInterrupt("Interrupted during linting")
804+
799805
self.lint_outcome = not lint_errors
800806

801807
if lint_errors:
@@ -898,7 +904,12 @@ async def reply_completed(self):
898904
" its outputs are no longer necessary"
899905
)
900906
self.io.tool_output(waiting_msg)
901-
await asyncio.sleep(command_timeout / 2)
907+
sleep_coro = asyncio.sleep(command_timeout / 2)
908+
_res, interrupted = await self.coroutines.interruptible(
909+
sleep_coro, self.interrupt_event
910+
)
911+
if interrupted:
912+
raise KeyboardInterrupt("Interrupted while waiting for background commands")
902913
return True
903914

904915
# Check for recently finished commands that need reflection

cecli/coders/base_coder.py

Lines changed: 130 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@
6161
from cecli.repo import ANY_GIT_ERROR, GitRepo
6262
from cecli.repomap import RepoMap
6363
from cecli.report import update_error_prefix
64-
from cecli.run_cmd import run_cmd
64+
from cecli.run_cmd import run_cmd_async
6565
from cecli.sessions import SessionManager
6666
from cecli.tools.utils.output import print_tool_response
6767
from cecli.tools.utils.registry import ToolRegistry
@@ -600,7 +600,9 @@ def __init__(
600600
self.files_edited_by_tools = set()
601601

602602
# Linting and testing
603-
self.linter = Linter(root=self.root, encoding=io.encoding)
603+
self.linter = Linter(
604+
root=self.root, encoding=io.encoding, interrupt_event=self.interrupt_event
605+
)
604606
self.auto_lint = auto_lint
605607
self.setup_lint_cmds(lint_cmds)
606608
self.lint_cmds = lint_cmds
@@ -2242,7 +2244,15 @@ async def send_message(self, inp):
22422244
import asyncio
22432245

22442246
loop = asyncio.get_running_loop()
2245-
result = await loop.run_in_executor(None, self.format_messages)
2247+
2248+
async def format_in_executor():
2249+
return await loop.run_in_executor(None, self.format_messages)
2250+
2251+
result, interrupted = await self.coroutines.interruptible(
2252+
format_in_executor(), self.interrupt_event
2253+
)
2254+
if interrupted:
2255+
raise KeyboardInterrupt("Interrupted during message formatting")
22462256
messages = result
22472257

22482258
if not await self.check_tokens(messages):
@@ -2451,7 +2461,10 @@ async def send_message(self, inp):
24512461
return
24522462

24532463
if edited and self.auto_lint:
2454-
lint_errors = self.lint_edited(edited)
2464+
lint_errors = await self.lint_edited(edited)
2465+
if lint_errors is None: # Interrupted
2466+
return
2467+
24552468
await self.auto_commit(edited, context="Ran the linter")
24562469
self.lint_outcome = not lint_errors
24572470
if lint_errors:
@@ -2952,12 +2965,16 @@ async def show_exhausted_error(self):
29522965
self.io.tool_error(res)
29532966
await self.io.offer_url(urls.token_limits)
29542967

2955-
def lint_edited(self, fnames, show_output=True):
2968+
async def lint_edited(self, fnames, show_output=True):
29562969
res = ""
29572970
for fname in fnames:
29582971
if not fname:
29592972
continue
2960-
errors = self.linter.lint(self.abs_root_path(fname))
2973+
try:
2974+
errors = await self.linter.lint(self.abs_root_path(fname))
2975+
except asyncio.CancelledError:
2976+
self.io.tool_warning("Linting interrupted.")
2977+
return None
29612978

29622979
if errors:
29632980
res += "\n"
@@ -3276,122 +3293,127 @@ async def show_send_output_stream(self, completion):
32763293
received_content = False
32773294
chunk_index = 0
32783295

3279-
async for chunk in completion:
3280-
if self.args.debug:
3281-
with open(".cecli/logs/chunks.log", "a") as f:
3282-
print(chunk, file=f)
3296+
try:
3297+
async for chunk in coroutines.interruptible_async_generator(
3298+
completion, self.interrupt_event
3299+
):
3300+
if self.args.debug:
3301+
with open(".cecli/logs/chunks.log", "a") as f:
3302+
print(chunk, file=f)
32833303

3284-
# Check if confirmation is in progress and wait if needed
3285-
if not self.io.confirmation_in_progress_event.is_set():
3286-
await self.io.confirmation_in_progress_event.wait()
3304+
# Check if confirmation is in progress and wait if needed
3305+
if not self.io.confirmation_in_progress_event.is_set():
3306+
await self.io.confirmation_in_progress_event.wait()
32873307

3288-
if isinstance(chunk, str):
3289-
self.io.tool_error(chunk)
3290-
continue
3291-
else:
3292-
if len(chunk.choices) == 0:
3308+
if isinstance(chunk, str):
3309+
self.io.tool_error(chunk)
32933310
continue
3311+
else:
3312+
if len(chunk.choices) == 0:
3313+
continue
32943314

3295-
if (
3296-
hasattr(chunk.choices[0], "finish_reason")
3297-
and chunk.choices[0].finish_reason == "length"
3298-
):
3299-
raise FinishReasonLength()
3300-
3301-
try:
3302-
if chunk.choices[0].delta.tool_calls:
3303-
received_content = True
3304-
self.token_profiler.on_token()
3305-
for tool_call_chunk in chunk.choices[0].delta.tool_calls:
3306-
self.tool_reflection = True
3307-
3308-
if tool_call_chunk.type:
3309-
self.io.update_spinner_suffix(tool_call_chunk.type)
3315+
if (
3316+
hasattr(chunk.choices[0], "finish_reason")
3317+
and chunk.choices[0].finish_reason == "length"
3318+
):
3319+
raise FinishReasonLength()
33103320

3311-
if tool_call_chunk.function:
3312-
if tool_call_chunk.function.name:
3313-
self.io.update_spinner_suffix(tool_call_chunk.function.name)
3321+
try:
3322+
if chunk.choices[0].delta.tool_calls:
3323+
received_content = True
3324+
self.token_profiler.on_token()
3325+
for tool_call_chunk in chunk.choices[0].delta.tool_calls:
3326+
self.tool_reflection = True
33143327

3315-
if tool_call_chunk.function.arguments:
3316-
self.io.update_spinner_suffix(
3317-
tool_call_chunk.function.arguments
3318-
)
3328+
if tool_call_chunk.type:
3329+
self.io.update_spinner_suffix(tool_call_chunk.type)
33193330

3320-
except (AttributeError, IndexError):
3321-
# Handle cases where the response structure doesn't match expectations
3322-
pass
3331+
if tool_call_chunk.function:
3332+
if tool_call_chunk.function.name:
3333+
self.io.update_spinner_suffix(tool_call_chunk.function.name)
33233334

3324-
try:
3325-
func = chunk.choices[0].delta.function_call
3326-
# dump(func)
3327-
if func:
3328-
for k, v in func.items():
3329-
self.tool_reflection = True
3330-
self.io.update_spinner_suffix(v)
3331-
3332-
received_content = True
3333-
self.token_profiler.on_token()
3334-
except AttributeError:
3335-
pass
3335+
if tool_call_chunk.function.arguments:
3336+
self.io.update_spinner_suffix(
3337+
tool_call_chunk.function.arguments
3338+
)
33363339

3337-
text = ""
3340+
except (AttributeError, IndexError):
3341+
# Handle cases where the response structure doesn't match expectations
3342+
pass
33383343

3339-
try:
3340-
reasoning_content = chunk.choices[0].delta.reasoning_content
3341-
except AttributeError:
33423344
try:
3343-
reasoning_content = chunk.choices[0].delta.reasoning
3345+
func = chunk.choices[0].delta.function_call
3346+
# dump(func)
3347+
if func:
3348+
for k, v in func.items():
3349+
self.tool_reflection = True
3350+
self.io.update_spinner_suffix(v)
3351+
3352+
received_content = True
3353+
self.token_profiler.on_token()
33443354
except AttributeError:
3345-
reasoning_content = None
3355+
pass
33463356

3347-
if reasoning_content:
3348-
if nested.getter(self.args, "show_thinking"):
3349-
if not self.got_reasoning_content:
3350-
text += f"<{REASONING_TAG}>\n\n"
3351-
text += reasoning_content
3352-
self.got_reasoning_content = True
3353-
received_content = True
3354-
self.token_profiler.on_token()
3355-
self.io.update_spinner_suffix(reasoning_content)
3356-
self.partial_response_reasoning_content += reasoning_content
3357+
text = ""
33573358

3358-
try:
3359-
content = chunk.choices[0].delta.content
3360-
if content:
3361-
if self.got_reasoning_content and not self.ended_reasoning_content:
3362-
text += f"\n\n</{self.reasoning_tag_name}>\n\n"
3363-
self.ended_reasoning_content = True
3364-
3365-
text += content
3366-
received_content = True
3359+
try:
3360+
reasoning_content = chunk.choices[0].delta.reasoning_content
3361+
except AttributeError:
3362+
try:
3363+
reasoning_content = chunk.choices[0].delta.reasoning
3364+
except AttributeError:
3365+
reasoning_content = None
3366+
3367+
if reasoning_content:
3368+
if nested.getter(self.args, "show_thinking"):
3369+
if not self.got_reasoning_content:
3370+
text += f"<{REASONING_TAG}>\n\n"
3371+
text += reasoning_content
3372+
self.got_reasoning_content = True
3373+
received_content = True
33673374
self.token_profiler.on_token()
3368-
self.io.update_spinner_suffix(content)
3369-
except AttributeError:
3370-
pass
3375+
self.io.update_spinner_suffix(reasoning_content)
3376+
self.partial_response_reasoning_content += reasoning_content
3377+
3378+
try:
3379+
content = chunk.choices[0].delta.content
3380+
if content:
3381+
if self.got_reasoning_content and not self.ended_reasoning_content:
3382+
text += f"\n\n</{self.reasoning_tag_name}>\n\n"
3383+
self.ended_reasoning_content = True
3384+
3385+
text += content
3386+
received_content = True
3387+
self.token_profiler.on_token()
3388+
self.io.update_spinner_suffix(content)
3389+
except AttributeError:
3390+
pass
33713391

3372-
self.partial_response_content += text
3392+
self.partial_response_content += text
33733393

3374-
chunk_index += 1
3375-
chunk._hidden_params["created_at"] = chunk_index
3376-
self.partial_response_chunks.append(chunk)
3394+
chunk_index += 1
3395+
chunk._hidden_params["created_at"] = chunk_index
3396+
self.partial_response_chunks.append(chunk)
33773397

3378-
if self.show_pretty():
3379-
# Use simplified streaming - just call the method with full content
3380-
content_to_show = self.live_incremental_response(False)
3381-
self.stream_wrapper(content_to_show, final=False)
3382-
elif text:
3383-
# Apply reasoning tag formatting for non-pretty output
3384-
if nested.getter(self.args, "show_thinking"):
3385-
text = replace_reasoning_tags(text, self.reasoning_tag_name)
3386-
try:
3387-
self.stream_wrapper(text, final=False)
3388-
except UnicodeEncodeError:
3389-
# Safely encode and decode the text
3390-
safe_text = text.encode(sys.stdout.encoding, errors="backslashreplace").decode(
3391-
sys.stdout.encoding
3392-
)
3393-
self.stream_wrapper(safe_text, final=False)
3394-
yield text
3398+
if self.show_pretty():
3399+
# Use simplified streaming - just call the method with full content
3400+
content_to_show = self.live_incremental_response(False)
3401+
self.stream_wrapper(content_to_show, final=False)
3402+
elif text:
3403+
# Apply reasoning tag formatting for non-pretty output
3404+
if nested.getter(self.args, "show_thinking"):
3405+
text = replace_reasoning_tags(text, self.reasoning_tag_name)
3406+
try:
3407+
self.stream_wrapper(text, final=False)
3408+
except UnicodeEncodeError:
3409+
# Safely encode and decode the text
3410+
safe_text = text.encode(
3411+
sys.stdout.encoding, errors="backslashreplace"
3412+
).decode(sys.stdout.encoding)
3413+
self.stream_wrapper(safe_text, final=False)
3414+
yield text
3415+
except (asyncio.CancelledError, KeyboardInterrupt):
3416+
raise KeyboardInterrupt
33953417

33963418
# The Part Doing the Heavy Lifting Now
33973419
self.consolidate_chunks()
@@ -4226,8 +4248,10 @@ async def handle_shell_commands(self, commands_str, group):
42264248
self.io.tool_output(f"Running {command}")
42274249
# Add the command to input history
42284250
# self.io.add_to_input_history(f"/run {command.strip()}")
4229-
exit_status, output = await asyncio.to_thread(
4230-
run_cmd, command, error_print=self.io.tool_error, cwd=self.root
4251+
exit_status, output = await run_cmd_async(
4252+
command,
4253+
self.interrupt_event,
4254+
cwd=self.root,
42314255
)
42324256

42334257
if output:

cecli/commands/run.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
1-
import asyncio
21
from typing import List
32

43
import cecli.prompts.utils.system as prompts
54
from cecli.commands.utils.base_command import BaseCommand
65
from cecli.commands.utils.helpers import format_command_result
76
from cecli.helpers.conversation import ConversationService, MessageTag
8-
from cecli.run_cmd import run_cmd
7+
from cecli.run_cmd import run_cmd_async
98

109

1110
class RunCommand(BaseCommand):
@@ -22,11 +21,10 @@ async def execute(cls, io, coder, args, **kwargs):
2221
if coder.args.tui:
2322
should_print = False
2423

25-
exit_status, combined_output = await asyncio.to_thread(
26-
run_cmd,
24+
exit_status, combined_output = await run_cmd_async(
2725
args,
26+
coder.interrupt_event,
2827
verbose=coder.args.verbose if hasattr(coder.args, "verbose") else False,
29-
error_print=io.tool_error,
3028
cwd=coder.root,
3129
should_print=should_print,
3230
)

0 commit comments

Comments
 (0)