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 922d5328671..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 @@ -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) @@ -2780,7 +2779,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/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 230b87f0745..d3cdf0b04d6 100644 --- a/cecli/io.py +++ b/cecli/io.py @@ -1718,13 +1718,13 @@ 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 @@ -1739,9 +1739,7 @@ def _send_notification(self): "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 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/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()