From 505da75ccad830f3999c7c8e5ddfd063642977e9 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 12 May 2026 16:47:24 -0700 Subject: [PATCH 1/9] fix: Remove redundant notification in tool call info printing Co-authored-by: cecli (openai/gemini_cli_local/gemini-2.5-pro) --- cecli/io.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cecli/io.py b/cecli/io.py index 230b87f0745..99e113a6c19 100644 --- a/cecli/io.py +++ b/cecli/io.py @@ -816,6 +816,7 @@ async def get_input( edit_format=None, ): self.rule() + self.notify_user_input_required() rel_fnames = list(rel_fnames) show = "" From e97ab06e7ea506f8fa80ecdc18aa35761fffe7ec Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 12 May 2026 20:25:25 -0700 Subject: [PATCH 2/9] fix: Remove notification on tool call and add to Finished tool Co-authored-by: cecli (openai/gemini_cli_local/gemini-2.5-pro) --- cecli/coders/base_coder.py | 1 - cecli/tools/finished.py | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index 922d5328671..0a6678472fb 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -2780,7 +2780,6 @@ async def process_tool_calls(self, tool_call_response): def _print_tool_call_info(self, server_tool_calls): """Print information about an MCP tool call.""" - self.io.ring_bell() # self.io.tool_output("Preparing to run MCP tools", bold=False) for server, tool_calls in server_tool_calls.items(): diff --git a/cecli/tools/finished.py b/cecli/tools/finished.py index c2e73192273..a80e93d7365 100644 --- a/cecli/tools/finished.py +++ b/cecli/tools/finished.py @@ -30,6 +30,7 @@ async def execute(cls, coder, **kwargs): if coder: coder.agent_finished = True + coder.io.notify_user_input_required() if coder.files_edited_by_tools: _ = await coder.auto_commit(coder.files_edited_by_tools) From 62db409b82e27b9bf0b9eb75212b236c8665aa4a Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 12 May 2026 20:38:39 -0700 Subject: [PATCH 3/9] refactor: Make notification calls async and use asyncio subprocess Co-authored-by: cecli (openai/gemini_cli_local/gemini-2.5-pro) --- cecli/coders/base_coder.py | 2 +- cecli/io.py | 36 ++++++++++++++++++------------------ cecli/tools/finished.py | 2 +- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index 0a6678472fb..a9d2c558912 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -1455,7 +1455,7 @@ async def input_task(self, preproc): # Check if we should recreate input if not coroutines.is_active(self.io.input_task): - self.io.ring_bell() + await self.io.ring_bell() await self.io.recreate_input() await asyncio.sleep(0.1) # Small yield to prevent tight loop diff --git a/cecli/io.py b/cecli/io.py index 230b87f0745..0252d858aca 100644 --- a/cecli/io.py +++ b/cecli/io.py @@ -1309,8 +1309,8 @@ async def _confirm_ask( self.user_input(f"{question} - {res}", log_only=False) else: # Ring the bell if needed - self.notify_user_input_required() - self.ring_bell() + await self.notify_user_input_required() + await self.ring_bell() self.start_spinner("Awaiting Confirmation...", False) while True: @@ -1385,12 +1385,12 @@ async def _confirm_ask( return is_yes - @restore_multiline - def prompt_ask(self, question, default="", subject=None): + @restore_multiline_async + async def prompt_ask(self, question, default="", subject=None): self.num_user_asks += 1 # Ring the bell if needed - self.ring_bell() + await self.ring_bell() if subject: self.tool_output() @@ -1405,14 +1405,16 @@ def prompt_ask(self, question, default="", subject=None): else: try: if self.prompt_session: - res = self.prompt_session.prompt( + res = await self.prompt_session.prompt_async( question + " ", default=default, style=style, complete_while_typing=True, ) else: - res = input(question + " ") + res = await asyncio.get_event_loop().run_in_executor( + None, input, question + " " + ) except EOFError: # Treat EOF (Ctrl+D) as if the user pressed Enter res = default @@ -1728,15 +1730,13 @@ def get_default_notification_command(self): return None # Unknown system - def _send_notification(self): + async def _send_notification(self): if self.notifications_command: try: - # Use Popen to run the command in the background without waiting for it - # and without capturing its output, detaching it from the current terminal session. + # Use asyncio.create_subprocess_shell for non-blocking execution kwargs = { - "shell": True, - "stdout": subprocess.DEVNULL, - "stderr": subprocess.DEVNULL, + "stdout": asyncio.subprocess.DEVNULL, + "stderr": asyncio.subprocess.DEVNULL, } if platform.system() == "Windows": kwargs["creationflags"] = ( @@ -1746,22 +1746,22 @@ def _send_notification(self): # For non-Windows systems, start a new session to detach kwargs["start_new_session"] = True - subprocess.Popen(self.notifications_command, **kwargs) + await asyncio.create_subprocess_shell(self.notifications_command, **kwargs) except Exception as e: self.tool_warning(f"Failed to run notifications command: {e}") else: print("\a", end="", flush=True) # Ring the bell - def notify_user_input_required(self): + async def notify_user_input_required(self): """Send a notification that user input is required.""" if self.notifications: - self._send_notification() + await self._send_notification() - def ring_bell(self): + async def ring_bell(self): """Ring the terminal bell if needed and clear the flag""" if self.bell_on_next_input and self.notifications: - self._send_notification() + await self._send_notification() self.bell_on_next_input = False def toggle_multiline_mode(self): diff --git a/cecli/tools/finished.py b/cecli/tools/finished.py index a80e93d7365..24ced1fc5f7 100644 --- a/cecli/tools/finished.py +++ b/cecli/tools/finished.py @@ -30,7 +30,7 @@ async def execute(cls, coder, **kwargs): if coder: coder.agent_finished = True - coder.io.notify_user_input_required() + await coder.io.notify_user_input_required() if coder.files_edited_by_tools: _ = await coder.auto_commit(coder.files_edited_by_tools) From a1aeacc518bdccf034bae011a9c3f92df07cae4d Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 12 May 2026 20:50:40 -0700 Subject: [PATCH 4/9] chore: Add debug logging for notifications Co-authored-by: cecli (openai/gemini_cli_local/gemini-2.5-pro) --- cecli/io.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cecli/io.py b/cecli/io.py index 0252d858aca..99c95e6b14e 100644 --- a/cecli/io.py +++ b/cecli/io.py @@ -1731,6 +1731,8 @@ def get_default_notification_command(self): return None # Unknown system async def _send_notification(self): + if self.verbose: + self.tool_output("Sending notification.", log_only=True) if self.notifications_command: try: # Use asyncio.create_subprocess_shell for non-blocking execution @@ -1751,6 +1753,8 @@ async def _send_notification(self): except Exception as e: self.tool_warning(f"Failed to run notifications command: {e}") else: + if self.verbose: + self.tool_output("Ringing terminal bell.", log_only=True) print("\a", end="", flush=True) # Ring the bell async def notify_user_input_required(self): From 4695e8e00d2bdda60f76211163f1d3c5dafa1f78 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 14 May 2026 10:45:53 -0700 Subject: [PATCH 5/9] fix --- cecli/__init__.py | 2 +- cecli/coders/agent_coder.py | 25 +++++---- cecli/coders/base_coder.py | 9 ++-- cecli/commands/clear.py | 3 +- cecli/commands/reset.py | 3 +- cecli/helpers/observations/manager.py | 13 +++-- cecli/io.py | 49 ++++++++--------- cecli/main.py | 22 +++++--- cecli/models.py | 20 +++++++ cecli/sessions.py | 4 +- cecli/tools/context_manager.py | 3 +- cecli/tools/edit_text.py | 6 +-- cecli/tools/finished.py | 1 - cecli/tools/load_skill.py | 5 +- cecli/tools/read_range.py | 41 +++++++++------ cecli/tools/remove_skill.py | 5 +- cecli/tools/thinking.py | 4 +- cecli/tools/utils/registry.py | 11 ++++ tests/basic/test_models.py | 52 +++++++++++++++++++ .../observations/test_observation_manager.py | 2 - 20 files changed, 187 insertions(+), 93 deletions(-) diff --git a/cecli/__init__.py b/cecli/__init__.py index 49025eeddbe..32ad82fb67b 100644 --- a/cecli/__init__.py +++ b/cecli/__init__.py @@ -1,6 +1,6 @@ from packaging import version -__version__ = "0.99.10.dev" +__version__ = "0.99.12.dev" safe_version = __version__ try: diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index 77db195c201..8524d707185 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -282,9 +282,7 @@ async def _exec_async(): content_parts.append(item.text) return "".join(content_parts) except Exception as e: - self.io.tool_warning( - (f"Executing {tool_name} on {server.name} failed:\nError: {e}") - ) + self.io.tool_warning(f"Executing {tool_name} on {server.name} failed:\nError: {e}") return f"Error executing tool call {tool_name}: {e}" result, interrupted = await interruptible(_exec_async(), self.interrupt_event) @@ -381,7 +379,10 @@ def get_context_symbol_outline(self): try: result = '\n' result += "## Symbol Outline (Current Context)\n\n" - result += "Code definitions (classes, functions, methods, etc.) found in files currently in chat context." + result += ( + "Code definitions (classes, functions, methods, etc.) found in files currently in" + " chat context." + ) result += "\n\n" files_to_outline = list(self.abs_fnames) + list(self.abs_read_only_fnames) if not files_to_outline: @@ -522,7 +523,10 @@ def get_context_summary(self): ) if editable_files: result += "\n".join(editable_files) + "\n\n" - result += f"**Total editable: {len(editable_files)} files, {editable_tokens:,} tokens**\n\n" + result += ( + f"**Total editable: {len(editable_files)} files," + f" {editable_tokens:,} tokens**\n\n" + ) else: result += "No editable files in context\n\n" if self.abs_read_only_fnames: @@ -542,7 +546,10 @@ def get_context_summary(self): ) if readonly_files: result += "\n".join(readonly_files) + "\n\n" - result += f"**Total read-only: {len(readonly_files)} files, {readonly_tokens:,} tokens**\n\n" + result += ( + f"**Total read-only: {len(readonly_files)} files," + f" {readonly_tokens:,} tokens**\n\n" + ) else: result += "No read-only files in context\n\n" extra_tokens = sum(self.context_block_tokens.values()) @@ -730,7 +737,7 @@ async def gather_and_await(): self.model_kwargs = {} result_message = f"Error executing {tool_name}: {e}" self.io.tool_error( - (f"Error during {tool_name} execution: {e}\n{traceback.format_exc()}") + f"Error during {tool_name} execution: {e}\n{traceback.format_exc()}" ) tool_responses.append( {"role": "tool", "tool_call_id": tool_call.id, "content": result_message} @@ -1020,8 +1027,8 @@ 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 you" - " are ready to edit a file." + " current content hash prefixes needed to perform an edit and activate them when" + " you are ready to edit a file." ) context_parts.append("\n\n") diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index a9d2c558912..fd205357282 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -453,7 +453,6 @@ def __init__( # Initialize conversation system if enabled ConversationService.get_chunks(self).initialize_conversation_system() - self.observation_manager = ObservationManager.get_instance(self) self.commands = commands or Commands(self.io, self, args=args) self.commands.coder = self @@ -1455,7 +1454,7 @@ async def input_task(self, preproc): # Check if we should recreate input if not coroutines.is_active(self.io.input_task): - await self.io.ring_bell() + self.io.ring_bell() await self.io.recreate_input() await asyncio.sleep(0.1) # Small yield to prevent tight loop @@ -1752,7 +1751,7 @@ async def compact_context_if_needed(self, force=False, message=""): return # Trigger background observation/reflection check - await self.observation_manager.check_and_trigger() + await ObservationManager.get_instance(self).check_and_trigger() manager = ConversationService.get_manager(self) done_messages = manager.get_messages_dict(MessageTag.DONE) @@ -1789,8 +1788,8 @@ async def summarize_and_update(messages, tag): if not text: raise ValueError(f"Summarization of {tag} messages returned empty.") - if self.observation_manager.observations: - obs_text = "\n".join(self.observation_manager.observations) + if ObservationManager.get_instance(self).observations: + obs_text = "\n".join(ObservationManager.get_instance(self).observations) text = f"HISTORICAL OBSERVATIONS:\n{obs_text}\n\n{text}" manager.clear_tag(tag) diff --git a/cecli/commands/clear.py b/cecli/commands/clear.py index c12449d1d98..f84567684d8 100644 --- a/cecli/commands/clear.py +++ b/cecli/commands/clear.py @@ -2,6 +2,7 @@ from cecli.commands.utils.base_command import BaseCommand from cecli.commands.utils.helpers import format_command_result +from cecli.helpers.observations.manager import ObservationManager class ClearCommand(BaseCommand): @@ -19,7 +20,7 @@ async def execute(cls, io, coder, args, **kwargs): ConversationService.get_manager(coder).clear_tag(MessageTag.FILE_CONTEXTS) ConversationService.get_files(coder).reset() - coder.observation_manager.reset() + ObservationManager.get_instance(coder).reset() # Clear TUI output if available if coder.tui and coder.tui(): diff --git a/cecli/commands/reset.py b/cecli/commands/reset.py index 2054b12b92e..fc6e64b0377 100644 --- a/cecli/commands/reset.py +++ b/cecli/commands/reset.py @@ -3,6 +3,7 @@ from cecli.commands.utils.base_command import BaseCommand from cecli.commands.utils.helpers import format_command_result from cecli.helpers.conversation import ConversationService +from cecli.helpers.observations.manager import ObservationManager class ResetCommand(BaseCommand): @@ -24,7 +25,7 @@ async def execute(cls, io, coder, args, **kwargs): # Re-initialize Conversation components with current coder ConversationService.get_manager(coder).initialize(reformat=True) ConversationService.get_files(coder) # Ensure instance exists/initialized - coder.observation_manager.reset() + ObservationManager.get_instance(coder).reset() # Clear TUI output if available if coder.tui and coder.tui(): diff --git a/cecli/helpers/observations/manager.py b/cecli/helpers/observations/manager.py index 3d0ebf5d864..81a44f326c9 100644 --- a/cecli/helpers/observations/manager.py +++ b/cecli/helpers/observations/manager.py @@ -26,17 +26,21 @@ async def check_and_trigger(self): if self.is_processing: return - manager = ConversationService.get_manager(self.coder) - cur_messages = manager.get_messages_dict(MessageTag.CUR) + cur_messages = ConversationService.get_manager(self.coder).get_messages_dict(MessageTag.CUR) # Calculate unobserved tokens unobserved = cur_messages[self._last_observed_index :] + current_index = len(cur_messages) + if not unobserved: return tokens = self.coder.summarizer.count_tokens(unobserved) - if tokens >= self.observation_threshold: + if ( + tokens >= self.observation_threshold + and (not self._last_observed_index or current_index - self._last_observed_index >= 10) + ) or tokens >= 2 * self.observation_threshold: asyncio.create_task(self.run_observation(unobserved)) self._last_observed_index = len(cur_messages) @@ -50,8 +54,7 @@ async def check_and_trigger(self): async def run_observation(self, messages): self.is_processing = True try: - manager = ConversationService.get_manager(self.coder) - all_messages = manager.get_messages_dict() + all_messages = ConversationService.get_manager(self.coder).get_messages_dict() prompt = self.coder.gpt_prompts.observation_prompt observation = await self.coder.summarizer.summarize_all_as_text( all_messages, prompt, max_tokens=8192 diff --git a/cecli/io.py b/cecli/io.py index 99c95e6b14e..e3df9142eec 100644 --- a/cecli/io.py +++ b/cecli/io.py @@ -816,6 +816,7 @@ async def get_input( edit_format=None, ): self.rule() + self.notify_user_input_required() rel_fnames = list(rel_fnames) show = "" @@ -1309,8 +1310,8 @@ async def _confirm_ask( self.user_input(f"{question} - {res}", log_only=False) else: # Ring the bell if needed - await self.notify_user_input_required() - await self.ring_bell() + self.notify_user_input_required() + self.ring_bell() self.start_spinner("Awaiting Confirmation...", False) while True: @@ -1385,12 +1386,12 @@ async def _confirm_ask( return is_yes - @restore_multiline_async - async def prompt_ask(self, question, default="", subject=None): + @restore_multiline + def prompt_ask(self, question, default="", subject=None): self.num_user_asks += 1 # Ring the bell if needed - await self.ring_bell() + self.ring_bell() if subject: self.tool_output() @@ -1405,16 +1406,14 @@ async def prompt_ask(self, question, default="", subject=None): else: try: if self.prompt_session: - res = await self.prompt_session.prompt_async( + res = self.prompt_session.prompt( question + " ", default=default, style=style, complete_while_typing=True, ) else: - res = await asyncio.get_event_loop().run_in_executor( - None, input, question + " " - ) + res = input(question + " ") except EOFError: # Treat EOF (Ctrl+D) as if the user pressed Enter res = default @@ -1720,52 +1719,48 @@ def get_default_notification_command(self): "$toastXml = $template.GetXml(); " "$toastXml.GetElementsByTagName('text')[0].AppendChild" "($template.CreateTextNode('cecli')) > $null; " - f"$toastXml.GetElementsByTagName('text')[1].AppendChild" + "$toastXml.GetElementsByTagName('text')[1].AppendChild" f"($template.CreateTextNode('{NOTIFICATION_MESSAGE}')) > $null; " "$toast = [Windows.UI.Notifications.ToastNotification]::new($toastXml); " "[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('cecli')" '.Show($toast)"' ) - return "powershell -command" + ps_command + return "powershell -WindowStyle Hidden -Command" + ps_command return None # Unknown system - async def _send_notification(self): - if self.verbose: - self.tool_output("Sending notification.", log_only=True) + def _send_notification(self): if self.notifications_command: try: - # Use asyncio.create_subprocess_shell for non-blocking execution + # Use Popen to run the command in the background without waiting for it + # and without capturing its output, detaching it from the current terminal session. kwargs = { - "stdout": asyncio.subprocess.DEVNULL, - "stderr": asyncio.subprocess.DEVNULL, + "shell": True, + "stdout": subprocess.DEVNULL, + "stderr": subprocess.DEVNULL, } if platform.system() == "Windows": - kwargs["creationflags"] = ( - subprocess.CREATE_NO_WINDOW | subprocess.DETACHED_PROCESS - ) + kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW else: # For non-Windows systems, start a new session to detach kwargs["start_new_session"] = True - await asyncio.create_subprocess_shell(self.notifications_command, **kwargs) + subprocess.Popen(self.notifications_command, **kwargs) except Exception as e: self.tool_warning(f"Failed to run notifications command: {e}") else: - if self.verbose: - self.tool_output("Ringing terminal bell.", log_only=True) print("\a", end="", flush=True) # Ring the bell - async def notify_user_input_required(self): + def notify_user_input_required(self): """Send a notification that user input is required.""" if self.notifications: - await self._send_notification() + self._send_notification() - async def ring_bell(self): + def ring_bell(self): """Ring the terminal bell if needed and clear the flag""" if self.bell_on_next_input and self.notifications: - await self._send_notification() + self._send_notification() self.bell_on_next_input = False def toggle_multiline_mode(self): diff --git a/cecli/main.py b/cecli/main.py index 0549ea78b55..89f637e0910 100644 --- a/cecli/main.py +++ b/cecli/main.py @@ -1194,15 +1194,21 @@ def get_io(pretty): if args.exit: return await graceful_exit(coder) if args.auto_load: - try: - from cecli.sessions import SessionManager + if await pre_init_io.confirm_ask( + "Do you want to load your previous session?", + acknowledge=True, + explicit_yes_required=True, + ): + try: + from cecli.sessions import SessionManager - session_manager = SessionManager(coder, io) - await session_manager.load_session( - args.auto_save_session_name if args.auto_save_session_name else "auto-save" - ) - except Exception: - pass + session_manager = SessionManager(coder, io) + await session_manager.load_session( + args.auto_save_session_name if args.auto_save_session_name else "auto-save", + switch=False, + ) + except Exception: + pass if suppress_pre_init: await graceful_exit(coder) diff --git a/cecli/models.py b/cecli/models.py index 495895bda12..19a6f8cff35 100644 --- a/cecli/models.py +++ b/cecli/models.py @@ -1172,6 +1172,26 @@ async def send_completion( sorted_tools = sorted( effective_tools, key=lambda x: x.get("function", {}).get("name", "Invalid Name") ) + + try: + # Deep copy to avoid modifying original tool schemas + sorted_tools = json.loads(json.dumps(sorted_tools)) + + for tool in sorted_tools: + function_schema = tool.get("function") + if function_schema and "description" in function_schema: + desc = function_schema.get("description") + if isinstance(desc, str): + # Escape the description string for JSON, but without the outer quotes. + # This is a workaround for issues with special characters in descriptions. + function_schema["description"] = json.dumps(desc, ensure_ascii=False)[ + 1:-1 + ] + except (TypeError, json.JSONDecodeError): + # If deep copy fails, proceed with original tools. + # This is a safeguard. + pass + kwargs["tools"] = sorted_tools if functions and len(functions) == 1: diff --git a/cecli/sessions.py b/cecli/sessions.py index 5ff6a5cd4e9..f1ee5a12570 100644 --- a/cecli/sessions.py +++ b/cecli/sessions.py @@ -88,7 +88,7 @@ def list_sessions(self) -> List[Dict]: return sessions - async def load_session(self, session_identifier: str) -> bool: + async def load_session(self, session_identifier: str, switch=True) -> bool: """Load a saved session by name or file path.""" if not session_identifier: self.io.tool_error("Please provide a session name or file path.") @@ -113,7 +113,7 @@ async def load_session(self, session_identifier: str) -> bool: # Apply session data applied, loaded_edit_format = await self._apply_session_data(session_data, session_file) - if applied: + if applied and switch: from cecli.commands import SwitchCoderSignal edit_format_to_switch_to = self.coder.edit_format diff --git a/cecli/tools/context_manager.py b/cecli/tools/context_manager.py index f969f48edcc..de565573f15 100644 --- a/cecli/tools/context_manager.py +++ b/cecli/tools/context_manager.py @@ -32,8 +32,7 @@ class Tool(BaseTool): "type": "array", "items": {"type": "string"}, "description": ( - "List of file paths to add as read-only. " - "Limit to at most 2 at a time." + "List of file paths to add as read-only. Limit to at most 2 at a time." ), }, "create": { diff --git a/cecli/tools/edit_text.py b/cecli/tools/edit_text.py index 7004e46d055..df6c8fc8c56 100644 --- a/cecli/tools/edit_text.py +++ b/cecli/tools/edit_text.py @@ -37,7 +37,7 @@ class Tool(BaseTool): "Can handle an array of up to 10 edits across multiple files. " "Each edit must include its own file_path and operation type. " "Use content hash ranges with the start_line and end_line parameters with format " - '"{4 char hash}" (without the braces). For empty files, use "@000" as the ' + "`{4 char hash}` (without the braces). For empty files, use `@000` as the " "content hash references." ), "parameters": { @@ -71,14 +71,14 @@ class Tool(BaseTool): "start_line": { "type": "string", "description": ( - 'Content hash for start line: "{4 char hash}" (without ' + "Content hash for start line: `{4 char hash}` (without " "the braces)" ), }, "end_line": { "type": "string", "description": ( - 'Content hash for end line: "{4 char hash}" (without the' + "Content hash for end line: `{4 char hash}` (without the" " braces)" ), }, diff --git a/cecli/tools/finished.py b/cecli/tools/finished.py index 24ced1fc5f7..c2e73192273 100644 --- a/cecli/tools/finished.py +++ b/cecli/tools/finished.py @@ -30,7 +30,6 @@ async def execute(cls, coder, **kwargs): if coder: coder.agent_finished = True - await coder.io.notify_user_input_required() if coder.files_edited_by_tools: _ = await coder.auto_commit(coder.files_edited_by_tools) diff --git a/cecli/tools/load_skill.py b/cecli/tools/load_skill.py index e8a6d4f5e4e..15f620579fb 100644 --- a/cecli/tools/load_skill.py +++ b/cecli/tools/load_skill.py @@ -7,10 +7,7 @@ class Tool(BaseTool): "type": "function", "function": { "name": "LoadSkill", - "description": ( - "Load a skill by name (agent mode only). Adds skill to include list and removes" - " from exclude list." - ), + "description": "Load a skill by name.", "parameters": { "type": "object", "properties": { diff --git a/cecli/tools/read_range.py b/cecli/tools/read_range.py index 0b0310ab997..822a2e9aeba 100644 --- a/cecli/tools/read_range.py +++ b/cecli/tools/read_range.py @@ -22,17 +22,16 @@ class Tool(BaseTool): "name": "ReadRange", "description": ( "Get content hash prefixes of content between start and end patterns in files." - " Accepts an array of `show` objects, each with file_path, start_text," - " end_text. These values must be lines from the content of the file." - " They can contain up to 3 lines but newlines should generally be avoided." - " Avoid using generic keywords and symbols." - "Special markers @000 and 000@ represent the file boundaries and can be" - " used for start_text and end_text for the first and last lines of" - " the file respectively. Avoid using both of the special markers together on non-empty files." - " Never use content hashes as the start_text and end_text values." - " Do not use the same pattern for the start_text and end_text." - " It is best to use function names, variable declarations and other block identifiers as " - " start_texts and end_texts." + " Accepts an array of `show` objects, each with file_path, start_text, end_text." + " These values must be lines from the content of the file. They can contain up to 3" + " lines but newlines should generally be avoided. Avoid using generic keywords and" + " symbols.Special markers @000 and 000@ represent the file boundaries and can be" + " used for start_text and end_text for the first and last lines of the file" + " respectively. Avoid using both of the special markers together on non-empty" + " files. Never use content hashes as the start_text and end_text values. Do not use" + " the same pattern for the start_text and end_text. It is best to use function" + " names, variable declarations and other block identifiers as start_texts and" + " end_texts." ), "parameters": { "type": "object", @@ -127,7 +126,10 @@ def execute(cls, coder, show, **kwargs): error_outputs.append( cls.format_error( coder, - f"Show operation {show_index + 1}: Provide both 'start_text' and 'end_text'.", + ( + f"Show operation {show_index + 1}: Provide both 'start_text' and" + " 'end_text'." + ), file_path, start_text, end_text, @@ -284,7 +286,10 @@ def execute(cls, coder, show, **kwargs): error_outputs.append( cls.format_error( coder, - f"End pattern '{end_text}' not found in {file_path}. Do not search for it again.", + ( + f"End pattern '{end_text}' not found in {file_path}. Do not" + " search for it again." + ), file_path, start_text, end_text, @@ -297,7 +302,10 @@ def execute(cls, coder, show, **kwargs): error_outputs.append( cls.format_error( coder, - f"End pattern '{end_text}' not found after start pattern in {file_path}.", + ( + f"End pattern '{end_text}' not found after start pattern in" + f" {file_path}." + ), file_path, start_text, end_text, @@ -314,7 +322,7 @@ def execute(cls, coder, show, **kwargs): cls.format_error( coder, ( - f"Special markers cannot be used for ranges greater than 200 lines." + "Special markers cannot be used for ranges greater than 200 lines." f" The resolved range is {e_idx - s_idx + 1} lines." " Pick more refined boundaries." ), @@ -466,7 +474,8 @@ def execute(cls, coder, show, **kwargs): ) if already_up_to_details: coder.io.tool_output( - f"Lines already up to date in context for {len(already_up_to_details)} operation(s)" + "Lines already up to date in context for" + f" {len(already_up_to_details)} operation(s)" ) detail_str = "\n".join(already_up_to_details) diff --git a/cecli/tools/remove_skill.py b/cecli/tools/remove_skill.py index feb2ae6e9de..70afb02ebfd 100644 --- a/cecli/tools/remove_skill.py +++ b/cecli/tools/remove_skill.py @@ -7,10 +7,7 @@ class Tool(BaseTool): "type": "function", "function": { "name": "RemoveSkill", - "description": ( - "Remove a skill by name (agent mode only). Removes skill from include list and adds" - " to exclude list." - ), + "description": "Remove a skill by name.", "parameters": { "type": "object", "properties": { diff --git a/cecli/tools/thinking.py b/cecli/tools/thinking.py index 9e40c3fe311..05a2ffa239b 100644 --- a/cecli/tools/thinking.py +++ b/cecli/tools/thinking.py @@ -11,8 +11,8 @@ class Tool(BaseTool): "function": { "name": "Thinking", "description": ( - "Use this tool to store useful facts for later " - "keep a scratch pad of your current efforts " + "Use this tool to store useful facts for later, " + "keep a scratch pad of your current efforts, " "and clarify your thoughts and intentions for your next steps." ), "parameters": { diff --git a/cecli/tools/utils/registry.py b/cecli/tools/utils/registry.py index 8c7e74706b9..45f333301ff 100644 --- a/cecli/tools/utils/registry.py +++ b/cecli/tools/utils/registry.py @@ -6,6 +6,7 @@ based on agent configuration. """ +import json import traceback from pathlib import Path from typing import Dict, List, Optional, Set, Type @@ -32,6 +33,16 @@ def register(cls, tool_class): except Exception: pass + description = tool_class.SCHEMA["function"]["description"] + wrapped = f'"{description}"' + + try: + json.loads(wrapped) + except json.JSONDecodeError: + tool_class.SCHEMA["function"]["description"] = json.dumps( + description, ensure_ascii=False + )[1:-1] + if not name and hasattr(tool_class, "NORM_NAME"): name = tool_class.NORM_NAME diff --git a/tests/basic/test_models.py b/tests/basic/test_models.py index 82a765171ad..5a9e5171d36 100644 --- a/tests/basic/test_models.py +++ b/tests/basic/test_models.py @@ -810,3 +810,55 @@ def test_print_matching_models_price_formatting(self): output_found = any("$10.50/1m/output" in call for call in calls) assert input_found, "Input pricing format incorrect" assert output_found, "Output pricing format incorrect" + + @patch("cecli.models.litellm.acompletion") + async def test_tool_description_escaping(self, mock_acompletion): + """ + Test that tool descriptions with special characters are properly escaped. + """ + model = Model("gpt-4") + messages = [{"role": "user", "content": "Hello"}] + + # A complex description with various special characters + complex_description = ( + 'This is a "test" description with `special` characters like \\, \n, and *.' + ) + + # Mock tool with the complex description + mock_tool = { + "type": "function", + "function": { + "name": "test_tool", + "description": complex_description, + "parameters": { + "type": "object", + "properties": {}, + "required": [], + }, + }, + } + + await model.send_completion(messages, functions=None, stream=False, tools=[mock_tool]) + + # Verify that acompletion was called + mock_acompletion.assert_called_once() + + # Get the keyword arguments passed to acompletion + call_kwargs = mock_acompletion.call_args.kwargs + + # Check that the 'tools' argument is present and correctly formatted + assert "tools" in call_kwargs + sent_tools = call_kwargs["tools"] + assert isinstance(sent_tools, list) + assert len(sent_tools) == 1 + + # Verify the description of the sent tool + sent_tool_function = sent_tools[0].get("function", {}) + sent_description = sent_tool_function.get("description") + + # The description should be a JSON-escaped string + # Expected: 'This is a \\"test\\" description with `special` characters like \\\\, \\n, and *.' + expected_escaped_description = ( + 'This is a \\"test\\" description with `special` characters like \\\\, \\n, and *.' + ) + assert sent_description == expected_escaped_description diff --git a/tests/helpers/observations/test_observation_manager.py b/tests/helpers/observations/test_observation_manager.py index e19dc7996a6..19eb60ac3bf 100644 --- a/tests/helpers/observations/test_observation_manager.py +++ b/tests/helpers/observations/test_observation_manager.py @@ -71,7 +71,6 @@ async def test_compact_context_with_observations(): # Mock observation manager with some observations obs_manager = ObservationManager.get_instance(coder) obs_manager.observations = ["Observation 1"] - coder.observation_manager = obs_manager # Mock prompts coder.gpt_prompts = MagicMock() @@ -136,7 +135,6 @@ async def test_compact_context_with_observations_integration(): # Mock observation manager with some observations obs_manager = ObservationManager.get_instance(coder) obs_manager.observations = ["Observation 1"] - coder.observation_manager = obs_manager # Mock prompts coder.gpt_prompts = MagicMock() From 7db69eb5e5c379c708d2d53a256e5014edbfd64e Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 14 May 2026 16:46:25 -0700 Subject: [PATCH 6/9] fix: Add notification for user input in get_input Co-authored-by: cecli (openai/gemini_cli_local/gemini-2.5-pro) --- cecli/io.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cecli/io.py b/cecli/io.py index e3df9142eec..42036d27b06 100644 --- a/cecli/io.py +++ b/cecli/io.py @@ -817,6 +817,7 @@ async def get_input( ): self.rule() self.notify_user_input_required() + self.notify_user_input_required() rel_fnames = list(rel_fnames) show = "" From 620a3c52a27169b803140bf29525be68f142fc4b Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 14 May 2026 16:49:16 -0700 Subject: [PATCH 7/9] fix: Add notification when user input is required Co-authored-by: cecli (openai/gemini_cli_local/gemini-2.5-pro) --- cecli/io.py | 1 - 1 file changed, 1 deletion(-) diff --git a/cecli/io.py b/cecli/io.py index 42036d27b06..e3df9142eec 100644 --- a/cecli/io.py +++ b/cecli/io.py @@ -817,7 +817,6 @@ async def get_input( ): self.rule() self.notify_user_input_required() - self.notify_user_input_required() rel_fnames = list(rel_fnames) show = "" From b90874e22bfbf9898e769746dc9e15aea6790244 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 15 May 2026 13:12:33 -0700 Subject: [PATCH 8/9] fix: Add TUI notifications for user input and confirmations Co-authored-by: cecli (openai/gemini_cli_local/gemini-2.5-pro) --- cecli/tui/io.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cecli/tui/io.py b/cecli/tui/io.py index 845466a2f92..695e67bd52b 100644 --- a/cecli/tui/io.py +++ b/cecli/tui/io.py @@ -355,6 +355,8 @@ async def get_input( """ self.interrupted = False + self.notify_user_input_required() + # Signal TUI that we're ready for input command_names = commands.get_commands() if commands else [] @@ -479,6 +481,9 @@ async def confirm_ask( res = group.preference self.user_input(f"{question} - {res}", log_only=False) else: + # Ring the bell to notify user + self.notify_user_input_required() + # Send confirmation request to TUI with full options self.output_queue.put( { From 3629d0a36f3afb685be435d6fd6a29dee99cfa6f Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 18 May 2026 11:07:23 -0700 Subject: [PATCH 9/9] fix: Add notification cooldown to prevent spam Co-authored-by: cecli (openai/gemini_cli_local/gemini-2.5-pro) --- cecli/io.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/cecli/io.py b/cecli/io.py index e3df9142eec..53eeb74b6ce 100644 --- a/cecli/io.py +++ b/cecli/io.py @@ -386,6 +386,7 @@ def __init__( self.verbose = verbose self.profile_start_time = None self.profile_last_time = None + self.last_notification_time = 0 # Variables used to interface with base_coder self.coder = None @@ -1730,6 +1731,12 @@ def get_default_notification_command(self): return None # Unknown system def _send_notification(self): + # Cooldown to prevent notification spam + current_time = time.time() + if current_time - self.last_notification_time < 2: # 2-second cooldown + return + self.last_notification_time = current_time + if self.notifications_command: try: # Use Popen to run the command in the background without waiting for it