Skip to content

Commit 765d7e2

Browse files
author
Your Name
committed
Add automatic command backgrounding to agent mode to prevent getting stuck on accidentally foregrounded, non-exiting commands
1 parent 6047ca9 commit 765d7e2

4 files changed

Lines changed: 217 additions & 198 deletions

File tree

cecli/coders/agent_coder.py

Lines changed: 50 additions & 178 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from datetime import datetime
1111
from pathlib import Path
1212

13-
from cecli import urls, utils
13+
from cecli import utils
1414
from cecli.change_tracker import ChangeTracker
1515
from cecli.helpers import nested
1616
from cecli.helpers.background_commands import BackgroundCommandManager
@@ -27,12 +27,10 @@
2727
from cecli.helpers.skills import SkillsManager
2828
from cecli.llm import litellm
2929
from cecli.mcp import LocalServer, McpServerManager
30-
from cecli.repo import ANY_GIT_ERROR
3130
from cecli.tools.utils.registry import ToolRegistry
3231
from cecli.utils import copy_tool_call, tool_call_to_dict
3332

3433
from .base_coder import Coder
35-
from .editblock_coder import do_replace, find_original_update_blocks, find_similar_lines
3634

3735

3836
class AgentCoder(Coder):
@@ -115,6 +113,7 @@ def _get_agent_config(self):
115113
config["skip_cli_confirmations"] = nested.getter(
116114
config, "skip_cli_confirmations", nested.getter(config, "yolo", [])
117115
)
116+
config["command_timeout"] = nested.getter(config, "command_timeout", 30)
118117

119118
config["tools_paths"] = nested.getter(config, "tools_paths", [])
120119
config["tools_includelist"] = nested.getter(
@@ -720,48 +719,61 @@ async def reply_completed(self):
720719
content = self.partial_response_content
721720
tool_calls_found = bool(self.partial_response_tool_calls)
722721

723-
# If no content and no tools, we might be done or just empty response
724-
if (not content or not content.strip()) and not tool_calls_found:
725-
if len(self.tool_usage_history) > self.tool_usage_retries:
726-
self.tool_usage_history = []
727-
return True
728-
729-
# 1. Handle Edit Blocks (SEARCH/REPLACE)
730-
has_search = "<<<<<<< SEARCH" in content
731-
has_divider = "=======" in content
732-
has_replace = ">>>>>>> REPLACE" in content
733-
edit_match = has_search and has_divider and has_replace
734-
735-
if edit_match:
736-
self.io.tool_output("Detected edit blocks, applying changes within Agent...")
737-
edited_files = await self._apply_edits_from_response()
738-
if self.reflected_message:
739-
return False
740-
if edited_files and self.num_reflections < self.max_reflections:
741-
cur_messages = ConversationManager.get_messages_dict(MessageTag.CUR)
742-
original_question = "Please continue your exploration and provide a final answer."
743-
if cur_messages:
744-
for msg in reversed(cur_messages):
745-
if msg["role"] == "user":
746-
original_question = msg["content"]
747-
break
748-
749-
next_prompt = f"""
750-
I have applied the edits you suggested.
751-
The following files were modified: {', '.join(edited_files)}. Let me continue working on your request.
752-
Your original question was: {original_question}"""
753-
self.reflected_message = next_prompt
754-
self.io.tool_output("Continuing after applying edits...")
755-
return False
756-
757-
# 2. Handle Tool Execution Follow-up (Reflection)
722+
# 1. Handle Tool Execution Follow-up (Reflection)
758723
if self.agent_finished:
759724
self.tool_usage_history = []
760725
self.reflected_message = None
761726
if self.files_edited_by_tools:
762727
_ = await self.auto_commit(self.files_edited_by_tools)
763728
return False
764729

730+
# 2. Check for unfinished and recently finished background commands
731+
background_commands = BackgroundCommandManager.list_background_commands()
732+
733+
# Get command timeout from agent_config
734+
command_timeout = int(self.agent_config.get("command_timeout", 30))
735+
736+
# Check for unfinished commands
737+
unfinished_commands = [
738+
cmd_key
739+
for cmd_key, cmd_info in background_commands.items()
740+
if cmd_info.get("running", False)
741+
]
742+
743+
# Check for recently finished commands (within last command_timeout seconds)
744+
current_time = time.time()
745+
recently_finished_commands = [
746+
cmd_key
747+
for cmd_key, cmd_info in background_commands.items()
748+
if not cmd_info.get("running", False)
749+
and cmd_info.get("end_time")
750+
and (current_time - cmd_info["end_time"]) < command_timeout * 4
751+
]
752+
753+
if unfinished_commands and not self.agent_finished:
754+
waiting_msg = (
755+
f"⏱️ Waiting for {len(unfinished_commands)} background command(s) to complete..."
756+
)
757+
self.reflected_message = (
758+
f"⏱️ Waiting for {len(unfinished_commands)} background command(s) to"
759+
" complete...\nPlease reply with 'waiting...' if you need the outputs of this"
760+
" command for your current task and it has not yet finished or stop the command if"
761+
" its outputs are no longer necessary"
762+
)
763+
self.io.tool_output(waiting_msg)
764+
await asyncio.sleep(command_timeout / 2)
765+
return True
766+
767+
# Check for recently finished commands that need reflection
768+
if recently_finished_commands and not self.agent_finished:
769+
return True # Retrigger reflection to process recently finished command outputs
770+
771+
# 3. If no content and no tools, we might be done or just empty response
772+
if (not content or not content.strip()) and not tool_calls_found:
773+
if len(self.tool_usage_history) > self.tool_usage_retries:
774+
self.tool_usage_history = []
775+
return True
776+
765777
if tool_calls_found and self.num_reflections < self.max_reflections:
766778
self.tool_call_count = 0
767779
self.files_added_in_exploration = set()
@@ -979,146 +991,6 @@ def _generate_write_context(self):
979991
return "\n".join(context_parts)
980992
return ""
981993

982-
async def _apply_edits_from_response(self):
983-
"""
984-
Parses and applies SEARCH/REPLACE edits found in self.partial_response_content.
985-
Returns a set of relative file paths that were successfully edited.
986-
"""
987-
edited_files = set()
988-
try:
989-
edits = list(
990-
find_original_update_blocks(
991-
self.partial_response_content, self.fence, self.get_inchat_relative_files()
992-
)
993-
)
994-
self.shell_commands += [edit[1] for edit in edits if edit[0] is None]
995-
edits = [edit for edit in edits if edit[0] is not None]
996-
prepared_edits = []
997-
seen_paths = dict()
998-
self.need_commit_before_edits = set()
999-
for edit in edits:
1000-
path = edit[0]
1001-
if path in seen_paths:
1002-
allowed = seen_paths[path]
1003-
else:
1004-
allowed = await self.allowed_to_edit(path)
1005-
seen_paths[path] = allowed
1006-
if allowed:
1007-
prepared_edits.append(edit)
1008-
await self.dirty_commit()
1009-
self.need_commit_before_edits = set()
1010-
failed = []
1011-
passed = []
1012-
for edit in prepared_edits:
1013-
path, original, updated = edit
1014-
full_path = self.abs_root_path(path)
1015-
new_content = None
1016-
if Path(full_path).exists():
1017-
content = self.io.read_text(full_path)
1018-
new_content = do_replace(full_path, content, original, updated, self.fence)
1019-
if not new_content and original.strip():
1020-
for other_full_path in self.abs_fnames:
1021-
if other_full_path == full_path:
1022-
continue
1023-
other_content = self.io.read_text(other_full_path)
1024-
other_new_content = do_replace(
1025-
other_full_path, other_content, original, updated, self.fence
1026-
)
1027-
if other_new_content:
1028-
path = self.get_rel_fname(other_full_path)
1029-
full_path = other_full_path
1030-
new_content = other_new_content
1031-
self.io.tool_warning(f"Applied edit intended for {edit[0]} to {path}")
1032-
break
1033-
if new_content:
1034-
if not self.dry_run:
1035-
self.io.write_text(full_path, new_content)
1036-
self.io.tool_output(f"Applied edit to {path}")
1037-
else:
1038-
self.io.tool_output(f"Did not apply edit to {path} (--dry-run)")
1039-
passed.append((path, original, updated))
1040-
else:
1041-
failed.append(edit)
1042-
if failed:
1043-
blocks = "block" if len(failed) == 1 else "blocks"
1044-
error_message = f"# {len(failed)} SEARCH/REPLACE {blocks} failed to match!\n"
1045-
for edit in failed:
1046-
path, original, updated = edit
1047-
full_path = self.abs_root_path(path)
1048-
content = self.io.read_text(full_path)
1049-
error_message += f"""
1050-
## SearchReplaceNoExactMatch: This SEARCH block failed to exactly match lines in {path}
1051-
<<<<<<< SEARCH
1052-
{original}=======
1053-
{updated}>>>>>>> REPLACE
1054-
1055-
"""
1056-
did_you_mean = find_similar_lines(original, content)
1057-
if did_you_mean:
1058-
error_message += f"""Did you mean to match some of these actual lines from {path}?
1059-
1060-
{self.fence[0]}
1061-
{did_you_mean}
1062-
{self.fence[1]}
1063-
1064-
"""
1065-
if updated in content and updated:
1066-
error_message += f"""Are you sure you need this SEARCH/REPLACE block?
1067-
The REPLACE lines are already in {path}!
1068-
1069-
"""
1070-
error_message += (
1071-
"The SEARCH section must exactly match an existing block of lines including all"
1072-
" white space, comments, indentation, docstrings, etc"
1073-
)
1074-
if passed:
1075-
pblocks = "block" if len(passed) == 1 else "blocks"
1076-
error_message += f"""
1077-
# The other {len(passed)} SEARCH/REPLACE {pblocks} were applied successfully.
1078-
Don't re-send them.
1079-
Just reply with fixed versions of the {blocks} above that failed to match.
1080-
"""
1081-
self.io.tool_error(error_message)
1082-
self.reflected_message = error_message
1083-
edited_files = set(edit[0] for edit in passed)
1084-
if edited_files:
1085-
self.coder_edited_files.update(edited_files)
1086-
self.auto_commit(edited_files)
1087-
if self.auto_lint:
1088-
lint_errors = self.lint_edited(edited_files)
1089-
self.auto_commit(edited_files, context="Ran the linter")
1090-
if lint_errors and not self.reflected_message:
1091-
ok = await self.io.confirm_ask("Attempt to fix lint errors?")
1092-
if ok:
1093-
self.reflected_message = lint_errors
1094-
shared_output = await self.run_shell_commands()
1095-
if shared_output:
1096-
self.io.tool_output("Shell command output:\n" + shared_output)
1097-
if self.auto_test and not self.reflected_message:
1098-
test_errors = await self.commands.execute("test", self.test_cmd)
1099-
if test_errors:
1100-
ok = await self.io.confirm_ask("Attempt to fix test errors?")
1101-
if ok:
1102-
self.reflected_message = test_errors
1103-
self.show_undo_hint()
1104-
except ValueError as err:
1105-
self.num_malformed_responses += 1
1106-
error_message = err.args[0]
1107-
self.io.tool_error("The LLM did not conform to the edit format.")
1108-
self.io.tool_output(urls.edit_errors)
1109-
self.io.tool_output()
1110-
self.io.tool_output(str(error_message))
1111-
self.reflected_message = str(error_message)
1112-
except ANY_GIT_ERROR as err:
1113-
self.io.tool_error(f"Git error during edit application: {str(err)}")
1114-
self.reflected_message = f"Git error during edit application: {str(err)}"
1115-
except Exception as err:
1116-
self.io.tool_error("Exception while applying edits:")
1117-
self.io.tool_error(str(err), strip=False)
1118-
self.io.tool_error(traceback.format_exc())
1119-
self.reflected_message = f"Exception while applying edits: {str(err)}"
1120-
return edited_files
1121-
1122994
def _add_file_to_context(self, file_path, explicit=False):
1123995
"""
1124996
Helper method to add a file to context as read-only.

0 commit comments

Comments
 (0)