From 0e2cafccb01fd8b00809fb1bb53363105cf3c942 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 29 Apr 2026 19:24:56 -0700 Subject: [PATCH 1/3] fix: Escape special characters in tool descriptions Co-authored-by: cecli (openai/gemini_cli_local/gemini-2.5-pro) --- cecli/models.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/cecli/models.py b/cecli/models.py index 0a48ae1304b..1fc91d3e29d 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: From 8c11bb0676b2964fd56e948acaa65596dc839850 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 29 Apr 2026 19:38:56 -0700 Subject: [PATCH 2/3] fix: Add test for tool description special character escaping Co-authored-by: cecli (openai/gemini_cli_local/gemini-2.5-pro) --- tests/basic/test_models.py | 48 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/basic/test_models.py b/tests/basic/test_models.py index 82a765171ad..9138d49f37f 100644 --- a/tests/basic/test_models.py +++ b/tests/basic/test_models.py @@ -810,3 +810,51 @@ 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 From b77e031cd0c38a36a4a56f39e4c8922d871c5725 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 11 May 2026 22:05:37 -0700 Subject: [PATCH 3/3] cli-17: formatted --- cecli/coders/agent_coder.py | 25 +++++++++++++-------- cecli/io.py | 2 +- cecli/tools/context_manager.py | 3 +-- cecli/tools/read_range.py | 41 +++++++++++++++++++++------------- tests/basic/test_models.py | 18 +++++++++------ 5 files changed, 54 insertions(+), 35 deletions(-) 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/io.py b/cecli/io.py index 230b87f0745..ef439eba6a6 100644 --- a/cecli/io.py +++ b/cecli/io.py @@ -1718,7 +1718,7 @@ 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')" 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/read_range.py b/cecli/tools/read_range.py index 0b0310ab997..66cafedd73b 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/tests/basic/test_models.py b/tests/basic/test_models.py index 9138d49f37f..5a9e5171d36 100644 --- a/tests/basic/test_models.py +++ b/tests/basic/test_models.py @@ -818,9 +818,11 @@ async def test_tool_description_escaping(self, mock_acompletion): """ 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 *.' + complex_description = ( + 'This is a "test" description with `special` characters like \\, \n, and *.' + ) # Mock tool with the complex description mock_tool = { @@ -840,21 +842,23 @@ async def test_tool_description_escaping(self, mock_acompletion): # 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 *.' + expected_escaped_description = ( + 'This is a \\"test\\" description with `special` characters like \\\\, \\n, and *.' + ) assert sent_description == expected_escaped_description