From 2958be4ef8c1580fd42468f1cbb112b8833824f3 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 27 Apr 2026 21:34:25 -0400 Subject: [PATCH 1/9] Move model overrides to global settings in models.py --- cecli/main.py | 148 +++--------------- cecli/models.py | 166 ++++++++++++++++++++- cecli/prompts/agent.yml | 12 +- cecli/website/docs/config/model-aliases.md | 41 ++++- 4 files changed, 230 insertions(+), 137 deletions(-) diff --git a/cecli/main.py b/cecli/main.py index f4a4b2843ed..bf8b89fa99d 100644 --- a/cecli/main.py +++ b/cecli/main.py @@ -361,64 +361,12 @@ def register_litellm_models(git_root, model_metadata_fname, io, verbose=False): def load_model_overrides(git_root, model_overrides_fname, io, verbose=False): """Load model tag overrides from a YAML file.""" - from pathlib import Path - - import yaml - - model_overrides_files = generate_search_path_list( - ".cecli.model.overrides.yml", git_root, model_overrides_fname - ) - overrides = {} - files_loaded = [] - for fname in model_overrides_files: - try: - if Path(fname).exists(): - with open(fname, "r") as f: - content = yaml.safe_load(f) - if content: - for model_name, tags in content.items(): - if model_name not in overrides: - overrides[model_name] = {} - overrides[model_name].update(tags) - files_loaded.append(fname) - except Exception as e: - io.tool_error(f"Error loading model overrides from {fname}: {e}") - if len(files_loaded) > 0 and verbose: - io.tool_output("Loaded model overrides from:") - for file_loaded in files_loaded: - io.tool_output(f" - {file_loaded}") - if ( - model_overrides_fname - and model_overrides_fname not in files_loaded - and model_overrides_fname != ".cecli.model.overrides.yml" - ): - io.tool_warning(f"Model Overrides File Not Found: {model_overrides_fname}") - return overrides + models.ModelOverrides.load_from_file(git_root, model_overrides_fname, io, verbose=verbose) def load_model_overrides_from_string(model_overrides_str, io): """Load model tag overrides from a JSON/YAML string.""" - import json - - import yaml - - overrides = {} - if not model_overrides_str: - return overrides - try: - try: - content = json.loads(model_overrides_str) - except json.JSONDecodeError: - content = yaml.safe_load(model_overrides_str) - if content and isinstance(content, dict): - for model_name, tags in content.items(): - if model_name not in overrides: - overrides[model_name] = {} - overrides[model_name].update(tags) - return overrides - except Exception as e: - io.tool_error(f"Error parsing model overrides string: {e}") - return {} + models.ModelOverrides.load_from_string(model_overrides_str, io) async def sanity_check_repo(repo, io): @@ -847,105 +795,52 @@ def get_io(pretty): if not selected_model_name: return await graceful_exit(None, 1) args.model = selected_model_name - model_overrides = {} + + # Load model tag overrides (stored in ModelOverrides class-level state) if args.model_overrides_file: - model_overrides = load_model_overrides( + models.ModelOverrides.load_from_file( git_root, args.model_overrides_file, io, verbose=args.verbose ) + if args.model_overrides: - direct_overrides = load_model_overrides_from_string(args.model_overrides, io) - for model_name, tags in direct_overrides.items(): - if model_name not in model_overrides: - model_overrides[model_name] = {} - model_overrides[model_name].update(tags) - override_index = {} - for base_model, suffixes in model_overrides.items(): - if not isinstance(suffixes, dict): - continue - for suffix, cfg in suffixes.items(): - if not isinstance(cfg, dict): - continue - full_name = f"{base_model}:{suffix}" - override_index[full_name] = base_model, cfg - - def apply_model_overrides(model_name): - """Return (effective_model_name, override_kwargs) for a given model_name. - - If model_name ends with ":suffix" where suffix is configured for the - prefix (everything before the last colon), we switch to the prefix model - and apply that override dict. Otherwise we leave the name unchanged - and return empty overrides. - """ - if not model_name: - return model_name, {} - prefix = "" - if model_name.startswith(models.COPY_PASTE_PREFIX): - prefix = models.COPY_PASTE_PREFIX - model_name = model_name[len(prefix) :] - - # Try to find a matching override by checking all possible suffix matches. - # We iterate from right to left splitting on colons to handle cases where - # the base model name itself contains colons (e.g. "provider/model:tag:alias") - parts = model_name.split(":") - # We need at least one split to have a base and a suffix - for i in range(len(parts) - 1, 0, -1): - potential_base = ":".join(parts[:i]) - potential_suffix = ":".join(parts[i:]) - - # Check if this base has the suffix configured - if potential_base in model_overrides: - suffixes = model_overrides[potential_base] - if isinstance(suffixes, dict) and potential_suffix in suffixes: - cfg = suffixes[potential_suffix] - if isinstance(cfg, dict): - model_name = prefix + potential_base - return model_name, cfg.copy() - - # No match found - model_name = prefix + model_name - return model_name, {} - - main_model_name, main_model_overrides = apply_model_overrides(args.model) - weak_model_name, weak_model_overrides = apply_model_overrides(args.weak_model) - editor_model_name, editor_model_overrides = apply_model_overrides(args.editor_model) - agent_model_name, agent_model_overrides = apply_model_overrides(args.agent_model) + models.ModelOverrides.load_from_string(args.model_overrides, io) + weak_model_obj = None - if weak_model_name: + if args.weak_model: weak_model_obj = models.Model( - weak_model_name, + args.weak_model, weak_model=False, verbose=args.verbose, io=io, - override_kwargs=weak_model_overrides, retries=args.retries, debug=args.debug, ) + editor_model_obj = None - if editor_model_name: + if args.editor_model: editor_model_obj = models.Model( - editor_model_name, + args.editor_model, editor_model=False, verbose=args.verbose, io=io, - override_kwargs=editor_model_overrides, retries=args.retries, debug=args.debug, ) + agent_model_obj = None - if agent_model_name: + if args.agent_model: agent_model_obj = models.Model( - agent_model_name, + args.agent_model, agent_model=False, verbose=args.verbose, io=io, - override_kwargs=agent_model_overrides, retries=args.retries, debug=args.debug, ) - if main_model_name.startswith("openrouter/") and not os.environ.get("OPENROUTER_API_KEY"): + if args.model.startswith("openrouter/") and not os.environ.get("OPENROUTER_API_KEY"): io.tool_warning( - f"The specified model '{main_model_name}' requires an OpenRouter API key, which was not" + f"The specified model '{args.model}' requires an OpenRouter API key, which was not" " found." ) if await offer_openrouter_oauth(io): @@ -958,24 +853,25 @@ def apply_model_overrides(model_name): return await graceful_exit(None, 1) else: io.tool_error( - f"Unable to proceed without an OpenRouter API key for model '{main_model_name}'." + f"Unable to proceed without an OpenRouter API key for model '{args.model}'." ) await io.offer_url( urls.models_and_keys, "Open documentation URL for more info?", acknowledge=True ) return await graceful_exit(None, 1) + main_model = models.Model( - main_model_name, + args.model, weak_model=weak_model_obj, editor_model=editor_model_obj, agent_model=agent_model_obj, editor_edit_format=args.editor_edit_format, verbose=args.verbose, io=io, - override_kwargs=main_model_overrides, retries=args.retries, debug=args.debug, ) + if args.copy_paste and main_model.copy_paste_transport == "api": main_model.enable_copy_paste_mode() if main_model.remove_reasoning is not None: diff --git a/cecli/models.py b/cecli/models.py index 08bc9996f80..0cf4a6a880c 100644 --- a/cecli/models.py +++ b/cecli/models.py @@ -20,7 +20,7 @@ from cecli.dump import dump from cecli.exceptions import LiteLLMExceptions from cecli.helpers import nested -from cecli.helpers.file_searcher import handle_core_files +from cecli.helpers.file_searcher import generate_search_path_list, handle_core_files from cecli.helpers.model_providers import ModelProviderManager from cecli.helpers.nested import deep_merge from cecli.helpers.requests import model_request_parser @@ -310,6 +310,152 @@ def fetch_openrouter_model_info(self, model): model_info_manager = ModelInfoManager() +class ModelOverrides: + """Class-level manager for model tag overrides (suffix-based model name resolution). + + Allows users to define model tag overrides like "model:suffix" that map to + a base model name with specific configuration overrides. + + State is stored at the class level so it can be loaded once (e.g. from a YAML file + in the entry point) and automatically applied when Model is instantiated. + """ + + _overrides: dict = {} + _defaults_overrides: dict = {} + + @classmethod + def load_from_file(cls, git_root, model_overrides_fname, io, verbose=False): + """Load model tag overrides from a YAML file.""" + from pathlib import Path + + import yaml + + model_overrides_files = generate_search_path_list( + ".cecli.model.overrides.yml", git_root, model_overrides_fname + ) + overrides = {} + files_loaded = [] + for fname in model_overrides_files: + try: + if Path(fname).exists(): + with open(fname, "r") as f: + content = yaml.safe_load(f) + if content: + for model_name, tags in content.items(): + if model_name not in overrides: + overrides[model_name] = {} + overrides[model_name].update(tags) + files_loaded.append(fname) + except Exception as e: + io.tool_error(f"Error loading model overrides from {fname}: {e}") + if len(files_loaded) > 0 and verbose: + io.tool_output("Loaded model overrides from:") + for file_loaded in files_loaded: + io.tool_output(f" - {file_loaded}") + if ( + model_overrides_fname + and model_overrides_fname not in files_loaded + and model_overrides_fname != ".cecli.model.overrides.yml" + ): + io.tool_warning(f"Model Overrides File Not Found: {model_overrides_fname}") + cls._from_file_or_string(overrides) + + @classmethod + def load_from_string(cls, model_overrides_str, io): + """Load model tag overrides from a JSON/YAML string.""" + import json + + import yaml + + overrides = {} + if not model_overrides_str: + return + try: + try: + content = json.loads(model_overrides_str) + except json.JSONDecodeError: + content = yaml.safe_load(model_overrides_str) + if content and isinstance(content, dict): + for model_name, tags in content.items(): + if model_name not in overrides: + overrides[model_name] = {} + overrides[model_name].update(tags) + except Exception as e: + io.tool_error(f"Error parsing model overrides string: {e}") + return + cls._from_file_or_string(overrides) + + @classmethod + def _from_file_or_string(cls, overrides): + """Internal: process raw overrides dict into class-level state.""" + # Extract 'defaults' key for direct model name matching + defaults_overrides = {} + if "defaults" in overrides: + defaults_overrides = overrides.pop("defaults") + if not isinstance(defaults_overrides, dict): + defaults_overrides = {} + + # Merge into class-level state + for model_name, tags in overrides.items(): + if model_name not in cls._overrides: + cls._overrides[model_name] = {} + if isinstance(tags, dict): + cls._overrides[model_name].update(tags) + + # Merge defaults + for model_name, tags in defaults_overrides.items(): + if model_name not in cls._defaults_overrides: + cls._defaults_overrides[model_name] = {} + if isinstance(tags, dict): + cls._defaults_overrides[model_name].update(tags) + + @classmethod + def apply(cls, model_name): + """Return (effective_model_name, override_kwargs) for a given model_name. + + If model_name ends with ":suffix" where suffix is configured for the + prefix (everything before the last colon), we return the prefix model + and the associated override dict. Otherwise we leave the name unchanged + and return empty overrides. + + NOTE: COPY_PASTE_PREFIX handling is done by Model.__init__ before this + method is called, so we only work with the clean model name here. + """ + if not model_name: + return model_name, {} + + # Try to find a matching override by checking all possible suffix matches. + # We iterate from right to left splitting on colons to handle cases where + # the base model name itself contains colons (e.g. "provider/model:tag:alias") + parts = model_name.split(":") + for i in range(len(parts) - 1, 0, -1): + potential_base = ":".join(parts[:i]) + potential_suffix = ":".join(parts[i:]) + + # Check if this base has the suffix configured + if potential_base in cls._overrides: + suffixes = cls._overrides[potential_base] + if isinstance(suffixes, dict) and potential_suffix in suffixes: + cfg = suffixes[potential_suffix] + if isinstance(cfg, dict): + return potential_base, cfg.copy() + + # Check for direct match in defaults overrides + if model_name in cls._defaults_overrides: + cfg = cls._defaults_overrides[model_name] + if isinstance(cfg, dict): + return model_name, cfg.copy() + + # No match found + return model_name, {} + + @classmethod + def clear(cls): + """Reset all model overrides.""" + cls._overrides = {} + cls._defaults_overrides = {} + + class Model(ModelSettings): def __init__( self, @@ -357,6 +503,20 @@ def __init__( else: model = provided_model model = MODEL_ALIASES.get(model, model) + + # Resolve model tag overrides (suffix-based model name resolution) + resolved_model, resolved_overrides = ModelOverrides.apply(model) + + if resolved_overrides: + model = resolved_model + merged = resolved_overrides.copy() + + if override_kwargs: + merged.update(override_kwargs) + + override_kwargs = merged + self.override_kwargs = override_kwargs + self.name = model self.max_chat_history_tokens = 1024 self.weak_model = None @@ -1005,13 +1165,17 @@ async def send_completion( override_kwargs.pop("temperature", None) effective_tools = tools + if effective_tools is None and functions: effective_tools = [dict(type="function", function=f) for f in functions] + if effective_tools: sorted_tools = sorted( effective_tools, key=lambda x: x.get("function", {}).get("name", "Invalid Name") ) kwargs["tools"] = sorted_tools + kwargs["parallel_tool_calls"] = True + if functions and len(functions) == 1: function = functions[0] if "name" in function: diff --git a/cecli/prompts/agent.yml b/cecli/prompts/agent.yml index 180b8addcb2..fb41448eb69 100644 --- a/cecli/prompts/agent.yml +++ b/cecli/prompts/agent.yml @@ -19,7 +19,7 @@ main_system: | ## Core Directives **Act Proactively**: Autonomously use discovery and management tools to fulfill the request. Chain tool calls across multiple turns for continuous exploration. **Be Decisive**: Trust your findings. Do not repeat identical searches or ask redundant questions. - **Be Efficient**: You may use multiple tools each turn. Batch tool calls when tools allow you to. Respect usage limits while maximizing the utility of each turn. + **Be Efficient**: Use multiple tools each turn when possible. Batch tool calls when tools allow you to. Respect usage limits while maximizing the utility of each turn. **Be Persistent**: Do not take short cuts. Work through your task until completion. No task takes too long as long as you are making progress towards the goal. @@ -46,14 +46,8 @@ main_system: | ## Todo List Management - Use `UpdateTodoList` to keep the state synchronized as you complete subtasks. - - Break complex tasks and long edits into granular steps so they remain tractable and context-efficient + - Break complex tasks and long edits into smaller steps so they remain tractable and context-efficient - ### Editing Tool Protocol - - **MANDATORY Two-Phase Safety Protocol**: - 1. **Phase 1**: Use `ShowContext` to gather the hashline-prefixed content of the section to modify. Capture entire functions, logical blocks and closures. - 2. **Phase 2**: Execute the edit (`ReplaceText`, `InsertText`, `DeleteText`) using the verified hashlines prefixes from the `ShowContext` tool. - **Atomic Scope:** Include the **entire function or logical block**. Never return partial syntax or broken closures. Do not attempt to replace just the beginning or end of a closure. **Indentation**: Preserve all necessary whitespace (spaces, tabs, and newlines) and stylistic indentation. @@ -66,7 +60,7 @@ system_reminder: | ## Operational Rules - **Scope**: No unrequested refactors. Edit files incrementally; avoid full-file rewrites. - **Hygiene**: Use `ContextManager`/`RemoveSkill` to evict unneeded files/skills immediately after use. - - **Verification**: You MUST use `ShowContext` to verify hashline ranges before every edit. + - **Preparation**: Use `ShowContext` to verify hashline ranges before every edit. Capture entire functions, logical blocks and closures. - **Outputs**: Tool calls trigger turns. Never include tool syntax in final user summaries. - **Sandbox**: Perform all verification and temp logic in `.cecli/temp`. - **Vibe**: Zero conversational filler. Do not confirm instructions or state "I understand." Provide status updates only when you have new information. diff --git a/cecli/website/docs/config/model-aliases.md b/cecli/website/docs/config/model-aliases.md index df726caca31..4d46098cf4d 100644 --- a/cecli/website/docs/config/model-aliases.md +++ b/cecli/website/docs/config/model-aliases.md @@ -97,7 +97,7 @@ for alias, model in sorted(MODEL_ALIASES.items()): ## Advanced Model Settings -CECLI/Cecli supports model names with colon-separated suffixes (e.g., `gpt-5:high`) that map to additional configuration parameters defined in the relevant config.yml file. This allows you to create named configurations for different use cases. These configurations map precisely to the LiteLLM `completion()` method parameters [here](https://docs.litellm.ai/docs/completion/input), though more are supported for specific models and providers. +CECLI/Cecli supports model names with colon-separated suffixes (e.g., `gpt-5:high`) that map to additional configuration parameters defined in the relevant config.yml file. This allows you to create named configurations for different use cases. These configurations map precisely to the LiteLLM `completion()` method parameters [here](https://docs.litellm.ai/docs/completion/input), though more are supported for specific models and providers. Any key under the `model_settings` key will override the model parameters defined in files like `.cecli.model.settings.yml` (more information [here](https://cecli.dev/docs/config/adv-model-settings.html)) ### Configuration File @@ -152,6 +152,45 @@ cecli --model gpt-5 --editor-model gpt-5:creative 3. The corresponding configuration parameters are applied to the model's API calls. 4. The parameters are deep-merged into the model's existing settings, with overrides taking precedence. + +### Default Overrides + +In addition to suffix-based overrides, you can define **default overrides** that apply directly to a model by name without requiring a colon-separated suffix. Use the special `defaults` key within your `model-overrides` configuration: + +```yaml +model-overrides: + defaults: + gpt-5: + temperature: 0.7 + top_p: 0.9 + claude-4-5-sonnet: + temperature: 1 + model_settings: + cache_control: true +``` + +When you run `cecli --model gpt-5`, the default overrides specified under `defaults` are applied automatically. This is useful for setting baseline parameters for specific models without creating a named configuration. + +Default overrides work alongside suffix-based overrides. If both a default override and a suffix override match the same parameter, the suffix override takes precedence: + +```bash +# Applies default overrides for gpt-5 +cecli --model gpt-5 + +# Applies suffix-based overrides for gpt-5:high, merged on top of defaults +cecli --model gpt-5:high +``` + +```yaml +model-overrides: + defaults: + gpt-5: + temperature: 0.7 + gpt-5: + high: + temperature: 0.9 # Overrides the default of 0.7 +``` + ### Priority Model overrides work alongside aliases. For example, you can use: From bb59057a3cb3feec74ae457a505c990b6597fadb Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 27 Apr 2026 22:38:30 -0400 Subject: [PATCH 2/9] Fix submodel kwarg inheritence --- cecli/models.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/cecli/models.py b/cecli/models.py index 0cf4a6a880c..2890a5cd18d 100644 --- a/cecli/models.py +++ b/cecli/models.py @@ -471,9 +471,7 @@ def __init__( io = kwargs.get("io", nested.getter(from_model, "io", None)) verbose = kwargs.get("verbose", nested.getter(from_model, "verbose", False)) - override_kwargs = kwargs.get( - "override_kwargs", nested.getter(from_model, "override_kwargs", None) - ) + override_kwargs = kwargs.get("override_kwargs", None) retries = kwargs.get("retries", nested.getter(from_model, "retries", None)) debug = kwargs.get("debug", nested.getter(from_model, "debug", False)) @@ -595,7 +593,7 @@ def configure_model_settings(self, model): valid_model_settings_fields = {f.name for f in fields(ModelSettings)} for key, value in self.override_kwargs.items(): - if key == "model_settings": + if key == "model_settings" or key == "model-settings": if not isinstance(value, dict): raise ValueError( f"override_kwargs 'model_settings' must be a dict, got {type(value)}" @@ -1174,7 +1172,6 @@ async def send_completion( effective_tools, key=lambda x: x.get("function", {}).get("name", "Invalid Name") ) kwargs["tools"] = sorted_tools - kwargs["parallel_tool_calls"] = True if functions and len(functions) == 1: function = functions[0] @@ -1288,7 +1285,12 @@ async def send_completion( await asyncio.sleep(retry_delay) continue - async def simple_send_with_retries(self, messages, max_tokens=None): + async def simple_send_with_retries( + self, + messages, + max_tokens=None, + override_kwargs={}, + ): from cecli.exceptions import LiteLLMExceptions litellm_ex = LiteLLMExceptions() @@ -1298,7 +1300,11 @@ async def simple_send_with_retries(self, messages, max_tokens=None): while True: try: _hash, response = await self.send_completion( - messages=messages, functions=None, stream=False, max_tokens=max_tokens + messages=messages, + functions=None, + stream=False, + max_tokens=max_tokens, + override_kwargs=override_kwargs, ) if ( not response From 799055be17cfaebb82c874ba2997a23fe673fdcb Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 27 Apr 2026 22:39:03 -0400 Subject: [PATCH 3/9] Disable thinking for commit messages --- cecli/repo.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/cecli/repo.py b/cecli/repo.py index 8cf61dee83b..f9f6f7467a2 100644 --- a/cecli/repo.py +++ b/cecli/repo.py @@ -428,7 +428,14 @@ async def get_commit_message(self, diffs, context, user_language=None): if max_tokens and num_tokens > max_tokens: continue - commit_message = await model.simple_send_with_retries(messages) + commit_message = await model.simple_send_with_retries( + messages, + override_kwargs={ + "reasoning_effort": None, + "thinking": None, + "drop_params": True, + }, + ) if commit_message: break # Found a model that could generate the message From 4e6009d7346272467891ce9912f306fb4449af4e Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 27 Apr 2026 22:56:24 -0400 Subject: [PATCH 4/9] Fix model overrides tests --- tests/basic/test_main.py | 42 ++++++++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index 4bd8456e842..9daa7b3d94f 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -968,6 +968,10 @@ def test_model_precedence(dummy_io, git_temp_dir, monkeypatch): def test_model_overrides_suffix_applied(dummy_io, git_temp_dir, mocker): + from cecli.models import ModelOverrides + + ModelOverrides.clear() + overrides_file = git_temp_dir / ".cecli.model.overrides.yml" overrides_file.write_text("gpt-4o:\n fast:\n temperature: 0.1\n") MockModel = mocker.patch("cecli.models.Model") @@ -991,18 +995,22 @@ def test_model_overrides_suffix_applied(dummy_io, git_temp_dir, mocker): **dummy_io, force_git_root=git_temp_dir, ) - matched_call_found = False - for call_args in MockModel.call_args_list: - args, kwargs = call_args - if args and args[0] == "gpt-4o" and kwargs.get("override_kwargs") == {"temperature": 0.1}: - matched_call_found = True - break - assert ( - matched_call_found - ), "Expected a Model call with base name 'gpt-4o' and override_kwargs {'temperature': 0.1}" + # Verify the override resolution using ModelOverrides class + resolved_model, resolved_overrides = ModelOverrides.apply("gpt-4o:fast") + assert resolved_model == "gpt-4o", f"Expected 'gpt-4o', got '{resolved_model}'" + assert resolved_overrides == { + "temperature": 0.1 + }, f"Expected {{temperature: 0.1}}, got {resolved_overrides}" + + # Clean up class-level state + ModelOverrides.clear() def test_model_overrides_no_match_preserves_model_name(dummy_io, git_temp_dir, mocker): + from cecli.models import ModelOverrides + + ModelOverrides.clear() + MockModel = mocker.patch("cecli.models.Model") MockCoder = mocker.patch("cecli.coders.Coder.create") mock_coder_instance = MagicMock() @@ -1025,15 +1033,15 @@ def test_model_overrides_no_match_preserves_model_name(dummy_io, git_temp_dir, m **dummy_io, force_git_root=git_temp_dir, ) - matched_call_found = False - for call_args in MockModel.call_args_list: - args, kwargs = call_args - if args and args[0] == model_name and kwargs.get("override_kwargs") == {}: - matched_call_found = True - break + # Verify the model name is preserved using ModelOverrides class + resolved_model, resolved_overrides = ModelOverrides.apply(model_name) assert ( - matched_call_found - ), "Expected a Model call with the full model name preserved and empty override_kwargs" + resolved_model == model_name + ), f"Expected model name '{model_name}', got '{resolved_model}'" + assert resolved_overrides == {}, f"Expected empty overrides, got {resolved_overrides}" + + # Clean up class-level state + ModelOverrides.clear() def test_chat_language_spanish(dummy_io, git_temp_dir): From 0a5f505ad3aa0ed2802b7f0515d329a8f5c28dc5 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 27 Apr 2026 23:30:48 -0400 Subject: [PATCH 5/9] #496: Add cecli user agent --- cecli/models.py | 12 ++++++++++-- tests/basic/test_models.py | 14 +++++++------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/cecli/models.py b/cecli/models.py index 2890a5cd18d..04e47c7d4ac 100644 --- a/cecli/models.py +++ b/cecli/models.py @@ -1216,9 +1216,17 @@ async def send_completion( } if kwargs.get("headers", None): - kwargs["headers"].update({"Connection": "close"}) + kwargs["headers"].update( + { + "User-Agent": f"cecli/{__version__}", + "Connection": "close", + } + ) else: - kwargs["headers"] = {"Connection": "close"} + kwargs["headers"] = { + "User-Agent": f"cecli/{__version__}", + "Connection": "close", + } litellm_ex = LiteLLMExceptions() retry_delay = 0.125 diff --git a/tests/basic/test_models.py b/tests/basic/test_models.py index 5da3de76669..82a765171ad 100644 --- a/tests/basic/test_models.py +++ b/tests/basic/test_models.py @@ -382,7 +382,7 @@ async def test_ollama_num_ctx_set_when_missing(self, mock_token_count, mock_comp num_ctx=expected_ctx, timeout=600, drop_params=True, - headers={"Connection": "close"}, + headers={"Connection": "close", "User-Agent": ANY}, cache_control_injection_points=ANY, ) @@ -427,7 +427,7 @@ async def test_ollama_uses_existing_num_ctx(self, mock_completion): num_ctx=4096, timeout=600, drop_params=True, - headers={"Connection": "close"}, + headers={"Connection": "close", "User-Agent": ANY}, cache_control_injection_points=ANY, ) @@ -444,7 +444,7 @@ async def test_non_ollama_no_num_ctx(self, mock_completion): temperature=0, timeout=600, drop_params=True, - headers={"Connection": "close"}, + headers={"Connection": "close", "User-Agent": ANY}, cache_control_injection_points=ANY, ) assert "num_ctx" not in mock_completion.call_args.kwargs @@ -477,7 +477,7 @@ async def test_request_timeout_default(self, mock_completion): temperature=0, timeout=600, drop_params=True, - headers={"Connection": "close"}, + headers={"Connection": "close", "User-Agent": ANY}, cache_control_injection_points=ANY, ) @@ -495,7 +495,7 @@ async def test_request_timeout_from_extra_params(self, mock_completion): temperature=0, timeout=300, drop_params=True, - headers={"Connection": "close"}, + headers={"Connection": "close", "User-Agent": ANY}, cache_control_injection_points=ANY, ) @@ -513,7 +513,7 @@ async def test_use_temperature_in_send_completion(self, mock_completion): temperature=0, timeout=600, drop_params=True, - headers={"Connection": "close"}, + headers={"Connection": "close", "User-Agent": ANY}, cache_control_injection_points=ANY, ) @@ -536,7 +536,7 @@ async def test_use_temperature_in_send_completion(self, mock_completion): temperature=0.7, timeout=600, drop_params=True, - headers={"Connection": "close"}, + headers={"Connection": "close", "User-Agent": ANY}, cache_control_injection_points=ANY, ) From 97d33020fd06e50b009712bd3be9907dbb2ba4fc Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 27 Apr 2026 23:33:43 -0400 Subject: [PATCH 6/9] Rename `ShowContext` tool to `GetLines` so its intention is clearer to the models --- cecli/coders/agent_coder.py | 4 ++-- cecli/prompts/agent.yml | 2 +- cecli/tools/__init__.py | 4 ++-- cecli/tools/delete_text.py | 2 +- cecli/tools/{show_context.py => get_lines.py} | 10 +++++----- cecli/tools/insert_text.py | 2 +- cecli/tools/replace_text.py | 2 +- .../tools/{test_show_context.py => test_get_lines.py} | 10 +++++----- 8 files changed, 18 insertions(+), 18 deletions(-) rename cecli/tools/{show_context.py => get_lines.py} (98%) rename tests/tools/{test_show_context.py => test_get_lines.py} (94%) diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index 372503b3780..4a4be4fe7bf 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -55,8 +55,8 @@ def __init__(self, *args, **kwargs): "commandinteractive", "explorecode", "ls", + "getlines", "grep", - "showcontext", "thinking", "updatetodolist", } @@ -999,7 +999,7 @@ def _generate_tool_context(self, repetitive_tools): context_parts.append("\n\n") context_parts.append("## File Editing Tools Disabled") context_parts.append( - "File editing tools are currently disabled.Use `ShowContext` to determine the" + "File editing tools are currently disabled.Use `GetLines` to determine the" " current hashline prefixes needed to perform an edit and activate them when you" " are ready to edit a file." ) diff --git a/cecli/prompts/agent.yml b/cecli/prompts/agent.yml index fb41448eb69..dabba8ec53b 100644 --- a/cecli/prompts/agent.yml +++ b/cecli/prompts/agent.yml @@ -60,7 +60,7 @@ system_reminder: | ## Operational Rules - **Scope**: No unrequested refactors. Edit files incrementally; avoid full-file rewrites. - **Hygiene**: Use `ContextManager`/`RemoveSkill` to evict unneeded files/skills immediately after use. - - **Preparation**: Use `ShowContext` to verify hashline ranges before every edit. Capture entire functions, logical blocks and closures. + - **Preparation**: Use `GetLines` to verify hashline ranges before every edit. Capture entire functions, logical blocks and closures. - **Outputs**: Tool calls trigger turns. Never include tool syntax in final user summaries. - **Sandbox**: Perform all verification and temp logic in `.cecli/temp`. - **Vibe**: Zero conversational filler. Do not confirm instructions or state "I understand." Provide status updates only when you have new information. diff --git a/cecli/tools/__init__.py b/cecli/tools/__init__.py index 22c3efaff32..59708e23ae4 100644 --- a/cecli/tools/__init__.py +++ b/cecli/tools/__init__.py @@ -9,6 +9,7 @@ delete_text, explore_code, finished, + get_lines, git_branch, git_diff, git_log, @@ -21,7 +22,6 @@ ls, remove_skill, replace_text, - show_context, thinking, undo_change, update_todo_list, @@ -35,6 +35,7 @@ delete_text, explore_code, finished, + get_lines, git_branch, git_diff, git_log, @@ -47,7 +48,6 @@ ls, remove_skill, replace_text, - show_context, thinking, undo_change, update_todo_list, diff --git a/cecli/tools/delete_text.py b/cecli/tools/delete_text.py index 3f5aea9b99b..b7c251820e8 100644 --- a/cecli/tools/delete_text.py +++ b/cecli/tools/delete_text.py @@ -62,7 +62,7 @@ def execute( if not coder.edit_allowed: raise ToolError( - "Please call `ShowContext` first to make sure edits are appropriately scoped" + "Please call `GetLines` first to make sure edits are appropriately scoped" ) tool_name = "DeleteText" diff --git a/cecli/tools/show_context.py b/cecli/tools/get_lines.py similarity index 98% rename from cecli/tools/show_context.py rename to cecli/tools/get_lines.py index 8aac09d485a..a7f39982032 100644 --- a/cecli/tools/show_context.py +++ b/cecli/tools/get_lines.py @@ -13,11 +13,11 @@ class Tool(BaseTool): - NORM_NAME = "showcontext" + NORM_NAME = "getlines" SCHEMA = { "type": "function", "function": { - "name": "ShowContext", + "name": "GetLines", "description": ( "Get hashline prefixes of context between start and end patterns in multiple files." " Accepts an array of show objects, each with file_path, start_text," @@ -84,7 +84,7 @@ def execute(cls, coder, show, **kwargs): Accepts an array of show operations to perform. Uses utility functions for path resolution and error handling. """ - tool_name = "ShowContext" + tool_name = "GetLines" already_up_to_date = False try: @@ -279,7 +279,7 @@ def execute(cls, coder, show, **kwargs): coder.io.tool_output("File contents already up to date") return ( "File contents already up to date." - "Do not call ShowContext again with these parameters until you edit the file." + "Do not call GetLines again with these parameters until you edit the file." ) else: coder.io.tool_output(f"✅ Successfully retrieved context for {len(show)} file(s)") @@ -294,7 +294,7 @@ def execute(cls, coder, show, **kwargs): @classmethod def format_output(cls, coder, mcp_server, tool_response): - """Format output for ShowContext tool.""" + """Format output for GetLines tool.""" color_start, color_end = color_markers(coder) try: diff --git a/cecli/tools/insert_text.py b/cecli/tools/insert_text.py index e12e0c15f76..4234f5fff6c 100644 --- a/cecli/tools/insert_text.py +++ b/cecli/tools/insert_text.py @@ -69,7 +69,7 @@ def execute( if not coder.edit_allowed: raise ToolError( - "Please call `ShowContext` first to make sure edits are appropriately scoped" + "Please call `GetLines` first to make sure edits are appropriately scoped" ) tool_name = "InsertText" diff --git a/cecli/tools/replace_text.py b/cecli/tools/replace_text.py index 5d3f5ed14de..d9e40c44e8b 100644 --- a/cecli/tools/replace_text.py +++ b/cecli/tools/replace_text.py @@ -85,7 +85,7 @@ def execute( if not coder.edit_allowed: raise ToolError( - "Please call `ShowContext` first to make sure edits are appropriately scoped" + "Please call `GetLines` first to make sure edits are appropriately scoped" ) tool_name = "ReplaceText" diff --git a/tests/tools/test_show_context.py b/tests/tools/test_get_lines.py similarity index 94% rename from tests/tools/test_show_context.py rename to tests/tools/test_get_lines.py index 20a1387f4ef..b70e6b12e96 100644 --- a/tests/tools/test_show_context.py +++ b/tests/tools/test_get_lines.py @@ -4,7 +4,7 @@ import pytest -from cecli.tools import show_context +from cecli.tools import get_lines class DummyIO: @@ -50,7 +50,7 @@ def coder_with_file(tmp_path): def test_pattern_with_zero_line_number_is_allowed(coder_with_file): coder, file_path = coder_with_file - result = show_context.Tool.execute( + result = get_lines.Tool.execute( coder, show=[ { @@ -70,7 +70,7 @@ def test_pattern_with_zero_line_number_is_allowed(coder_with_file): def test_empty_pattern_uses_line_number(coder_with_file): coder, file_path = coder_with_file - result = show_context.Tool.execute( + result = get_lines.Tool.execute( coder, show=[ { @@ -91,7 +91,7 @@ def test_conflicting_pattern_and_line_number_raise(coder_with_file): coder, file_path = coder_with_file # Test that missing start_text raises an error - result = show_context.Tool.execute( + result = get_lines.Tool.execute( coder, show=[ { @@ -126,7 +126,7 @@ def test_multiline_pattern_search(coder_with_file): coder, file_path = coder_with_file # file_path contains "alpha\nbeta\ngamma\n" - result = show_context.Tool.execute( + result = get_lines.Tool.execute( coder, show=[ { From f8d78cb8ae0cb664c53a6a7c296d65e91cc35569 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 28 Apr 2026 00:16:39 -0400 Subject: [PATCH 7/9] Unify ReplaceText, InsertText, and DeleteText, into a unified EditText tool --- cecli/change_tracker.py | 2 +- cecli/coders/agent_coder.py | 6 +- cecli/tools/__init__.py | 8 +- cecli/tools/delete_text.py | 139 ---------- cecli/tools/{replace_text.py => edit_text.py} | 246 ++++++++++++------ cecli/tools/insert_text.py | 150 ----------- cecli/tools/utils/registry.py | 2 +- cecli/website/docs/config/agent-mode.md | 27 +- tests/tools/test_insert_block.py | 88 ++++--- tests/tools/test_registry.py | 28 +- 10 files changed, 262 insertions(+), 434 deletions(-) delete mode 100644 cecli/tools/delete_text.py rename cecli/tools/{replace_text.py => edit_text.py} (55%) delete mode 100644 cecli/tools/insert_text.py diff --git a/cecli/change_tracker.py b/cecli/change_tracker.py index 48d38c21c3f..961471b32ec 100644 --- a/cecli/change_tracker.py +++ b/cecli/change_tracker.py @@ -21,7 +21,7 @@ def track_change( Parameters: - file_path: Path to the file that was changed - - change_type: Type of change (e.g., 'replacetext', 'insertlines') + - change_type: Type of change (e.g., 'edittext', 'insertlines') - original_content: Original content before the change - new_content: New content after the change - metadata: Additional information about the change (line numbers, positions, etc.) diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index 4a4be4fe7bf..30f52a193fb 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -61,9 +61,9 @@ def __init__(self, *args, **kwargs): "updatetodolist", } self.write_tools = { - "deletetext", - "inserttext", - "replacetext", + "command", + "commandinteractive", + "edittext", "undochange", } self.edit_allowed = False diff --git a/cecli/tools/__init__.py b/cecli/tools/__init__.py index 59708e23ae4..39639f525e3 100644 --- a/cecli/tools/__init__.py +++ b/cecli/tools/__init__.py @@ -6,7 +6,7 @@ command, command_interactive, context_manager, - delete_text, + edit_text, explore_code, finished, get_lines, @@ -17,11 +17,9 @@ git_show, git_status, grep, - insert_text, load_skill, ls, remove_skill, - replace_text, thinking, undo_change, update_todo_list, @@ -32,7 +30,7 @@ command, command_interactive, context_manager, - delete_text, + edit_text, explore_code, finished, get_lines, @@ -43,11 +41,9 @@ git_show, git_status, grep, - insert_text, load_skill, ls, remove_skill, - replace_text, thinking, undo_change, update_todo_list, diff --git a/cecli/tools/delete_text.py b/cecli/tools/delete_text.py deleted file mode 100644 index b7c251820e8..00000000000 --- a/cecli/tools/delete_text.py +++ /dev/null @@ -1,139 +0,0 @@ -from cecli.helpers.hashline import apply_hashline_operation -from cecli.tools.utils.base_tool import BaseTool -from cecli.tools.utils.helpers import ( - ToolError, - apply_change, - format_tool_result, - handle_tool_error, - validate_file_for_edit, -) - - -class Tool(BaseTool): - NORM_NAME = "deletetext" - TRACK_INVOCATIONS = False - SCHEMA = { - "type": "function", - "function": { - "name": "DeleteText", - "description": ( - "Delete a block of lines from a file using hashline markers. Uses start_line and" - ' end_line parameters with format "{4 char hash}" (without the braces) to specify' - " the range to delete." - ), - "parameters": { - "type": "object", - "properties": { - "file_path": {"type": "string"}, - "start_line": { - "type": "string", - "description": ( - 'Hashline format for start line: "{4 char hash}" (without the braces)' - ), - }, - "end_line": { - "type": "string", - "description": ( - 'Hashline format for end line: "{4 char hash}" (without the braces)' - ), - }, - "change_id": {"type": "string"}, - "dry_run": {"type": "boolean", "default": False}, - }, - "required": ["file_path", "start_line", "end_line"], - }, - }, - } - - @classmethod - def execute( - cls, - coder, - file_path, - start_line, - end_line, - change_id=None, - dry_run=False, - **kwargs, - ): - """ - Delete a block of text using hashline markers. - """ - - if not coder.edit_allowed: - raise ToolError( - "Please call `GetLines` first to make sure edits are appropriately scoped" - ) - - tool_name = "DeleteText" - try: - # 1. Validate file and get content - abs_path, rel_path, original_content = validate_file_for_edit(coder, file_path) - - # 2. Apply hashline operation for deletion - try: - new_content = apply_hashline_operation( - original_content=original_content, - start_line_hash=start_line, - end_line_hash=end_line, - operation="delete", - text=None, - ) - except Exception as e: - coder.edit_allowed = True - raise ToolError(f"Hashline deletion failed: {str(e)}") - - # Check if any changes were made - if original_content == new_content: - coder.io.tool_warning("No changes made: deletion would not change file") - return "Warning: No changes made (deletion would not change file)" - - # 3. Handle dry run - if dry_run: - dry_run_message = ( - f"Dry run: Would delete lines {start_line} to {end_line} in {file_path}." - ) - return format_tool_result( - coder, - tool_name, - "", - dry_run=True, - dry_run_message=dry_run_message, - ) - - # 4. Apply Change (Not dry run) - metadata = { - "start_line": start_line, - "end_line": end_line, - } - final_change_id = apply_change( - coder, - abs_path, - rel_path, - original_content, - new_content, - "deletetext", - metadata, - change_id, - ) - - coder.files_edited_by_tools.add(rel_path) - cls.clear_invocation_cache() - - # 5. Format and return result - success_message = f"Deleted lines {start_line} to {end_line} in {file_path}" - return format_tool_result( - coder, - tool_name, - success_message, - change_id=final_change_id, - ) - - except ToolError as e: - # Handle errors raised by utility functions (expected errors) - coder.edit_allowed = False - return handle_tool_error(coder, tool_name, e, add_traceback=False) - except Exception as e: - # Handle unexpected errors - coder.edit_allowed = False - return handle_tool_error(coder, tool_name, e) diff --git a/cecli/tools/replace_text.py b/cecli/tools/edit_text.py similarity index 55% rename from cecli/tools/replace_text.py rename to cecli/tools/edit_text.py index d9e40c44e8b..f03fc6d96df 100644 --- a/cecli/tools/replace_text.py +++ b/cecli/tools/edit_text.py @@ -16,20 +16,29 @@ ) from cecli.tools.utils.output import color_markers, tool_footer, tool_header +VALID_OPERATIONS = {"replace", "delete", "insert"} +OPERATION_NOUNS = { + "replace": "replacement", + "delete": "deletion", + "insert": "insertion", +} + class Tool(BaseTool): - NORM_NAME = "replacetext" + NORM_NAME = "edittext" TRACK_INVOCATIONS = False SCHEMA = { "type": "function", "function": { - "name": "ReplaceText", + "name": "EditText", "description": ( - "Replace text in one or more files. Can handle an array of up to 10 edits across" - " multiple files. Each edit must include its own file_path. Use hashline ranges" - " with the start_line and end_line parameters with format" - ' "{4 char hash}" (without the braces). For empty files, use "@000" as the hashline' - " references." + "Edit text in one or more files using hashline markers. " + "Supports replace, delete, and insert operations in a single call. " + "Can handle an array of up to 10 edits across multiple files. " + "Each edit must include its own file_path and operation type. " + "Use hashline ranges with the start_line and end_line parameters with format " + '"{4 char hash}" (without the braces). For empty files, use "@000" as the hashline ' + "references." ), "parameters": { "type": "object", @@ -43,27 +52,43 @@ class Tool(BaseTool): "type": "string", "description": "Required file path for this specific edit.", }, - "replace_text": {"type": "string"}, + "operation": { + "type": "string", + "enum": ["replace", "delete", "insert"], + "description": ( + "The type of operation: 'replace' (replace range with text), " + "'delete' (remove range), or 'insert' (insert text after start_line). " + "Defaults to 'replace'." + ), + }, + "text": { + "type": "string", + "description": ( + "Text content for replace/insert operations. " + "Not required for delete operations." + ), + }, "start_line": { "type": "string", "description": ( - 'Hashline format for start line: "{4 char hash}" (without' - " the braces)" + 'Hashline format for start line: "{4 char hash}" (without ' + "the braces)" ), }, "end_line": { "type": "string", "description": ( - 'Hashline format for end line: "{4 char hash}" (without the' - " braces)" + 'Hashline format for end line: "{4 char hash}" (without the ' + "braces)" ), }, }, - "required": ["file_path", "replace_text", "start_line", "end_line"], + "required": ["file_path"], }, "description": "Array of edits to apply.", }, "change_id": {"type": "string"}, + "dry_run": {"type": "boolean", "default": False}, }, "required": ["edits"], }, @@ -76,19 +101,20 @@ def execute( coder, edits=None, change_id=None, + dry_run=False, **kwargs, ): """ - Replace text in one or more files. Can handle single edit or array of edits across multiple files. + Edit text in one or more files. Supports replace, delete, and insert operations. + Can handle single edit or array of edits across multiple files. Each edit object must include its own file_path. """ - if not coder.edit_allowed: raise ToolError( "Please call `GetLines` first to make sure edits are appropriately scoped" ) - tool_name = "ReplaceText" + tool_name = "EditText" try: # 1. Validate edits parameter if not isinstance(edits, list): @@ -129,30 +155,64 @@ def execute( for edit_index, edit in file_edits: try: - edit_replace_text = strip_hashline(edit.get("replace_text")) - edit_start_line = edit.get("start_line") - edit_end_line = edit.get("end_line") - - if edit_replace_text is None: + operation = edit.get("operation", "replace") + if operation not in VALID_OPERATIONS: raise ToolError( - f"Edit {edit_index + 1} missing required replace_text parameter" + f"Edit {edit_index + 1}: Invalid operation '{operation}'. " + "Must be 'replace', 'delete', or 'insert'" ) - # Add operation to batch - operations.append( - { - "start_line_hash": edit_start_line, - "end_line_hash": edit_end_line, - "operation": "replace", - "text": edit_replace_text, - } + edit_text_raw = edit.get("text") + edit_text = ( + strip_hashline(edit_text_raw) if edit_text_raw is not None else None ) + edit_start_line = edit.get("start_line") + edit_end_line = edit.get("end_line") + + # Validate required fields based on operation type + if operation in ("replace", "insert"): + if edit_text is None: + raise ToolError( + f"Edit {edit_index + 1}: 'text' parameter is required for " + f"'{operation}' operation" + ) + if operation in ("replace", "delete"): + if edit_start_line is None: + raise ToolError( + f"Edit {edit_index + 1}: 'start_line' parameter is required " + f"for '{operation}' operation" + ) + if edit_end_line is None: + raise ToolError( + f"Edit {edit_index + 1}: 'end_line' parameter is required " + f"for '{operation}' operation" + ) + if operation == "insert": + if edit_start_line is None: + raise ToolError( + f"Edit {edit_index + 1}: 'start_line' parameter is required " + "for 'insert' operation" + ) + # For insert, end_line defaults to start_line + edit_end_line = edit_end_line or edit_start_line + + # Build operation dict for apply_hashline_operations + op_dict = { + "start_line_hash": edit_start_line, + "end_line_hash": edit_end_line, + "operation": operation, + } + if edit_text is not None: + op_dict["text"] = edit_text + + operations.append(op_dict) # Create metadata for this edit metadata = { + "operation": operation, "start_line": edit_start_line, "end_line": edit_end_line, - "replace_text": edit_replace_text, + "text": edit_text, } file_metadata.append(metadata) @@ -189,6 +249,23 @@ def execute( if original_content == new_content or file_successful_edits == 0: continue + # Handle dry run + if dry_run: + dry_run_message = ( + f"Dry run: Would apply {file_successful_edits} edit(s) " + f"in {file_path_key}" + ) + if file_failed_edits: + dry_run_message += f" ({len(file_failed_edits)} failed)" + all_results.append( + { + "file_path": file_path_key, + "dry_run": True, + "dry_run_message": dry_run_message, + } + ) + continue + # Apply Change metadata = { "edits": file_metadata, @@ -196,14 +273,13 @@ def execute( "failed_edits": file_failed_edits if file_failed_edits else None, } - # Apply the change (common path for both hashline and non-hashline cases) final_change_id = apply_change( coder, abs_path, rel_path, original_content, new_content, - "replacetext", + "edittext", metadata, change_id, ) @@ -228,20 +304,31 @@ def execute( all_failed_edits.append(f"Edit {edit_index + 1}: {str(e)}") continue + # If dry run, return all results + if dry_run: + dry_run_messages = "\n".join(r.get("dry_run_message", "") for r in all_results) + return format_tool_result( + coder, + tool_name, + "", + dry_run=True, + dry_run_message=dry_run_messages or "Dry run: No changes would be made", + ) + # 4. Check if any edits succeeded overall if total_successful_edits == 0: coder.edit_allowed = True error_msg = "No edits were successfully applied:\n" + "\n".join(all_failed_edits) raise ToolError(error_msg) - # 6. Format and return result + # 5. Format and return result # Log failed edit messages to console for visibility if all_failed_edits: for failed_msg in all_failed_edits: coder.io.tool_error(failed_msg) if files_processed == 1: - # Single file case for backward compatibility + # Single file case result = all_results[0] success_message = ( f"Applied {result['successful_edits']} edits in {result['file_path']}" @@ -272,11 +359,9 @@ def execute( ) except ToolError as e: - # Handle errors raised by utility functions or explicitly raised here coder.edit_allowed = False return handle_tool_error(coder, tool_name, e, add_traceback=False) except Exception as e: - # Handle unexpected errors coder.edit_allowed = False return handle_tool_error(coder, tool_name, e) @@ -294,7 +379,7 @@ def format_output(cls, coder, mcp_server, tool_response): # Group edits by file_path for display edits_by_file = {} - for i, edit in enumerate(params["edits"]): + for i, edit in enumerate(params.get("edits", [])): edit_file_path = edit.get("file_path") if edit_file_path not in edits_by_file: edits_by_file[edit_file_path] = [] @@ -309,44 +394,59 @@ def format_output(cls, coder, mcp_server, tool_response): coder.io.tool_output("") for edit_index, edit in file_edits: - # Show diff for this edit using hashline diff - replace_text = strip_hashline(edit.get("replace_text", "")) + operation = edit.get("operation", "replace") + + if len(params.get("edits", [])) > 1: + coder.io.tool_output( + f"{color_start}{OPERATION_NOUNS[operation]}_{edit_index + 1}:{color_end}" + ) + else: + coder.io.tool_output(f"{color_start}{OPERATION_NOUNS[operation]}:{color_end}") + + text = strip_hashline(edit.get("text", "")) start_line = edit.get("start_line") end_line = edit.get("end_line") + # Show output based on operation type + if operation == "replace": + # Show diff for replace operations + diff_output = "" - # Try to read the file to get original content for diff - diff_output = "" - - if file_path_key and start_line and end_line: - try: - # Try to read the file - abs_path = coder.abs_root_path(file_path_key) - original_content = coder.io.read_text(abs_path) - - if original_content is not None: - # Generate diff using get_hashline_diff - diff_output = get_hashline_diff( - original_content=strip_hashline(original_content), - start_line_hash=start_line, - end_line_hash=end_line, - operation="replace", - text=strip_hashline(replace_text), - ) - except HashlineError as e: - # If hashline verification fails, show the error - diff_output = f"Hashline verification failed: {str(e)}" - except Exception: - # If we can't read the file or generate diff, continue without it - pass - - # Only show diff section if we have diff output - if diff_output: - if len(params["edits"]) > 1: - coder.io.tool_output(f"{color_start}diff_{edit_index + 1}:{color_end}") - else: - coder.io.tool_output(f"{color_start}diff:{color_end}") - - coder.io.tool_output(diff_output) + if file_path_key and start_line and end_line: + try: + abs_path = coder.abs_root_path(file_path_key) + original_content = coder.io.read_text(abs_path) + + if original_content is not None: + diff_output = get_hashline_diff( + original_content=strip_hashline(original_content), + start_line_hash=start_line, + end_line_hash=end_line, + operation="replace", + text=strip_hashline(text), + ) + except HashlineError as e: + diff_output = f"Hashline verification failed: {str(e)}" + except Exception: + pass + + if diff_output: + coder.io.tool_output(diff_output) + coder.io.tool_output("") + + elif operation == "insert": + # Show inserted text + if text: + coder.io.tool_output(text) + coder.io.tool_output("") + + elif operation == "delete": + # Show deletion summary + range_info = ( + f"Deleted {start_line} - {end_line}" + if start_line and end_line + else "specified range" + ) + coder.io.tool_output(range_info) coder.io.tool_output("") tool_footer(coder=coder, tool_response=tool_response) diff --git a/cecli/tools/insert_text.py b/cecli/tools/insert_text.py deleted file mode 100644 index 4234f5fff6c..00000000000 --- a/cecli/tools/insert_text.py +++ /dev/null @@ -1,150 +0,0 @@ -from cecli.helpers.hashline import apply_hashline_operation, strip_hashline -from cecli.tools.utils.base_tool import BaseTool -from cecli.tools.utils.helpers import ( - ToolError, - apply_change, - format_tool_result, - handle_tool_error, - validate_file_for_edit, -) -from cecli.tools.utils.output import tool_body_unwrapped, tool_footer, tool_header - - -class Tool(BaseTool): - NORM_NAME = "inserttext" - TRACK_INVOCATIONS = False - SCHEMA = { - "type": "function", - "function": { - "name": "InsertText", - "description": ( - "Insert content into a file using hashline markers. " - 'Uses start_line parameter with format "{4 char hash}" (without the braces) ' - "to specify where to insert content. For empty files, " - 'use "@000" as the hashline reference. ' - "Note: Content will be inserted on the line AFTER the specified location" - ), - "parameters": { - "type": "object", - "properties": { - "file_path": {"type": "string"}, - "content": {"type": "string"}, - "start_line": { - "type": "string", - "description": ( - 'Hashline format for insertion point: "{4 char hash}" (without the' - " braces)" - ), - }, - "change_id": {"type": "string"}, - "dry_run": {"type": "boolean", "default": False}, - }, - "required": ["file_path", "content", "start_line"], - }, - }, - } - - @classmethod - def execute( - cls, - coder, - file_path, - content, - start_line, - change_id=None, - dry_run=False, - **kwargs, - ): - """ - Insert content into a file using hashline markers. - - Args: - coder: The coder instance - file_path: Path to the file to modify - content: The content to insert - start_line: Hashline format for insertion point: "{4 char hash}" (without the braces) - change_id: Optional ID for tracking changes - dry_run: If True, only simulate the change - """ - - if not coder.edit_allowed: - raise ToolError( - "Please call `GetLines` first to make sure edits are appropriately scoped" - ) - - tool_name = "InsertText" - try: - # 1. Validate file and get content - abs_path, rel_path, original_content = validate_file_for_edit(coder, file_path) - - # 2. Apply hashline operation for insertion - try: - new_content = apply_hashline_operation( - original_content=original_content, - start_line_hash=start_line, - end_line_hash=start_line, # For insert, end_line is same as start_line - operation="insert", - text=strip_hashline(content), - ) - except Exception as e: - coder.edit_allowed = True - raise ToolError(f"Hashline insertion failed: {str(e)}") - - # Check if any changes were made - if original_content == new_content: - coder.io.tool_warning("No changes made: insertion would not change file") - return "Warning: No changes made (insertion would not change file)" - - # 3. Handle dry run - if dry_run: - dry_run_message = f"Dry run: Would insert content at {start_line} in {file_path}." - return format_tool_result( - coder, - tool_name, - "", - dry_run=True, - dry_run_message=dry_run_message, - ) - - # 4. Apply Change (Not dry run) - metadata = { - "start_line": start_line, - "content": content, - } - final_change_id = apply_change( - coder, - abs_path, - rel_path, - original_content, - new_content, - "inserttext", - metadata, - change_id, - ) - - coder.files_edited_by_tools.add(rel_path) - cls.clear_invocation_cache() - - # 5. Format and return result - success_message = f"Inserted content at {start_line} in {file_path}" - return format_tool_result( - coder, - tool_name, - success_message, - change_id=final_change_id, - ) - - except ToolError as e: - # Handle errors raised by utility functions (expected errors) - coder.edit_allowed = False - return handle_tool_error(coder, tool_name, e, add_traceback=False) - - except Exception as e: - coder.edit_allowed = False - return handle_tool_error(coder, tool_name, e) - - @classmethod - def format_output(cls, coder, mcp_server, tool_response): - tool_header(coder=coder, mcp_server=mcp_server, tool_response=tool_response) - tool_body_unwrapped(coder=coder, tool_response=tool_response) - tool_footer(coder=coder, tool_response=tool_response) diff --git a/cecli/tools/utils/registry.py b/cecli/tools/utils/registry.py index 8f694334635..8c7e74706b9 100644 --- a/cecli/tools/utils/registry.py +++ b/cecli/tools/utils/registry.py @@ -18,7 +18,7 @@ class ToolRegistry: """Registry for tool discovery and management.""" _tools: Dict[str, Type] = {} # normalized name -> Tool class - _essential_tools: Set[str] = {"contextmanager", "replacetext", "finished"} + _essential_tools: Set[str] = {"contextmanager", "edittext", "finished"} _registry: Dict[str, Type] = {} # cached filtered registry loaded_custom_tools: List[str] = [] diff --git a/cecli/website/docs/config/agent-mode.md b/cecli/website/docs/config/agent-mode.md index 7ff5465f65b..985f0f5ca46 100644 --- a/cecli/website/docs/config/agent-mode.md +++ b/cecli/website/docs/config/agent-mode.md @@ -46,13 +46,12 @@ This loop continues automatically until the `Finished` tool is called, or the ma Agent Mode uses a centralized local tool registry that manages all available tools: -- **File Discovery Tools**: `ViewFilesMatching`, `ViewFilesWithSymbol`, `Ls`, `Grep` -- **Editing Tools**: `ReplaceText`, `InsertText`, `DeleteText` -- **Context Management Tools**: `ContextManager` +- **File Discovery Tools**: `ExploreCode`, `Ls`, `Grep` +- **Editing Tools**: `EditText`, +- **Context Management Tools**: `ContextManager`, `GetLines` - **Git Tools**: `GitDiff`, `GitLog`, `GitShow`, `GitStatus` -- **Utility Tools**: `UpdateTodoList`, `ListChanges`, `UndoChange`, `Finished` +- **Utility Tools**: `UpdateTodoList`, `UndoChange`, `Finished` - **Skill Management**: `LoadSkill`, `RemoveSkill` -- **Eval Management**: `RunEvals` #### Enhanced Context Management @@ -71,14 +70,6 @@ Agent Mode includes some useful context management features: - **Smart file removal**: Large files can be removed from context to save tokens - **Dynamic context updates**: Context blocks provide real-time project information -#### Granular Editing Capabilities - -Agent Mode prioritizes granular tools over SEARCH/REPLACE: - -- **Precision editing**: `ReplaceText` for targeted changes -- **Block operations**: `InsertText`, `DeleteText` for larger modifications -- **Refactoring support**: `ExtractLines` for code reorganization - #### Safety and Recovery - **Undo capability**: `UndoChange` tool for immediate recovery from mistakes @@ -122,10 +113,10 @@ Files are made editable and modifications are applied: Tool Call: MakeEditable Arguments: {"file_path": "main.py"} -Tool Call: ReplaceText +Tool Call: EditText Arguments: {"file_path": "main.py", "find_text": "old_function", "replace_text": "new_function"} -Tool Call: InsertText +Tool Call: EditText Arguments: {"file_path": "main.py", "after_pattern": "import statements", "content": "new_imports"} ``` @@ -159,7 +150,7 @@ Agent Mode can also be configured directly in the relevant config.yml file: agent: true agent-config: # Tool configuration - tools_includelist: [contextmanager", "replacetext", "finished"] # Optional: Whitelist of tools + tools_includelist: [contextmanager", "edittext", "finished"] # Optional: Whitelist of tools tools_excludelist: ["command", "commandinteractive"] # Optional: Blacklist of tools tools_paths: ["./custom-tools", "~/my-tools"] # Optional: Directories or files containing custom tools @@ -194,7 +185,7 @@ agent-config: Certain tools are always available regardless of includelist/excludelist settings: - `ContextManager` - Add, drop, and make files editable in the context -- `replacetext` - Basic text replacement +- `edittext` - Basic text replacement - `finished` - Complete the task The registry also supports **Custom Tools** that can be loaded from specified directories or files using the `tool_paths` configuration option. Custom tools must be Python files containing a `Tool` class that inherits from `BaseTool` and defines a `NORM_NAME` attribute. @@ -287,7 +278,7 @@ agent: true # Agent Mode configuration agent-config: # Tool configuration - tools_includelist: ["contextmanager", "replacetext", "finished"] # Optional: Whitelist of tools + tools_includelist: ["contextmanager", "edittext", "finished"] # Optional: Whitelist of tools tools_excludelist: ["command", "commandinteractive"] # Optional: Blacklist of tools tools_paths: ["./custom-tools", "~/my-tools"] # Optional: Directories or files containing custom tools diff --git a/tests/tools/test_insert_block.py b/tests/tools/test_insert_block.py index 99ec1600cc0..dfa93725cd7 100644 --- a/tests/tools/test_insert_block.py +++ b/tests/tools/test_insert_block.py @@ -5,7 +5,7 @@ import pytest from cecli.helpers.hashline import hashline -from cecli.tools import insert_text +from cecli.tools import edit_text class DummyIO: @@ -85,14 +85,19 @@ def test_position_top_succeeds_with_no_patterns(coder_with_file): hash_fragment = line1_hashline.split("::", 1)[0] # Everything before "::" start_line = hash_fragment # Just the hash fragment, no brackets - result = insert_text.Tool.execute( + result = edit_text.Tool.execute( coder, - file_path="example.txt", - content="inserted line", - start_line=start_line, + edits=[ + { + "file_path": "example.txt", + "operation": "insert", + "text": "inserted line", + "start_line": start_line, + }, + ], ) - assert result.startswith("Successfully executed InsertText.") + assert result.startswith("Successfully executed EditText.") lines = file_path.read_text().splitlines() assert lines[0] == "first line" # Original first line remains first assert lines[1] == "inserted line" # Inserted line comes after line 1 @@ -103,15 +108,20 @@ def test_mutually_exclusive_parameters_raise(coder_with_file): coder, file_path = coder_with_file # Test with invalid hashline format (missing pipe) - result = insert_text.Tool.execute( + result = edit_text.Tool.execute( coder, - file_path="example.txt", - content="new line", - start_line="invalid_hashline", + edits=[ + { + "file_path": "example.txt", + "operation": "insert", + "text": "new line", + "start_line": "invalid_hashline", + }, + ], ) - assert result.startswith("Error in InsertText:") - assert "Hashline insertion failed" in result + assert result.startswith("Error in EditText:") + assert "Invalid Edit - Source Not Modified" in result assert file_path.read_text().startswith("first line") coder.io.tool_error.assert_called() @@ -128,11 +138,16 @@ def test_trailing_newline_preservation(coder_with_file): hash_fragment = line1_hashline.split("::", 1)[0] # Everything before "::" start_line = hash_fragment # Just the hash fragment, no brackets - insert_text.Tool.execute( + edit_text.Tool.execute( coder, - file_path="example.txt", - content="inserted line", - start_line=start_line, + edits=[ + { + "file_path": "example.txt", + "operation": "insert", + "text": "inserted line", + "start_line": start_line, + }, + ], ) content = file_path.read_text() @@ -160,11 +175,16 @@ def test_no_trailing_newline_preservation(coder_with_file): # Extract hash fragment from {hash}::content format hash_fragment = line1_hashline.split("::", 1)[0] # Everything before "::" start_line = hash_fragment # Just the hash fragment, no brackets - insert_text.Tool.execute( + edit_text.Tool.execute( coder, - file_path="example.txt", - content="inserted line", - start_line=start_line, + edits=[ + { + "file_path": "example.txt", + "operation": "insert", + "text": "inserted line", + "start_line": start_line, + }, + ], ) content = file_path.read_text() @@ -188,14 +208,19 @@ def test_line_number_beyond_file_length_appends(coder_with_file): # Extract hash fragment from {hash}::content format hash_fragment = line2_hashline.split("::", 1)[0] # Everything before "::" start_line = hash_fragment # Just the hash fragment, no brackets - result = insert_text.Tool.execute( + result = edit_text.Tool.execute( coder, - file_path="example.txt", - content="appended line", - start_line=start_line, + edits=[ + { + "file_path": "example.txt", + "operation": "insert", + "text": "appended line", + "start_line": start_line, + }, + ], ) - assert result.startswith("Successfully executed InsertText.") + assert result.startswith("Successfully executed EditText.") content = file_path.read_text() assert content == "first line\nsecond line\nappended line\n" coder.io.tool_error.assert_not_called() @@ -216,11 +241,16 @@ def test_line_number_beyond_file_length_appends_no_trailing_newline(coder_with_f hash_fragment = line2_hashline.split("::", 1)[0] # Everything before "::" start_line = hash_fragment # Just the hash fragment, no brackets - insert_text.Tool.execute( + edit_text.Tool.execute( coder, - file_path="example.txt", - content="appended line", - start_line=start_line, + edits=[ + { + "file_path": "example.txt", + "operation": "insert", + "text": "appended line", + "start_line": start_line, + }, + ], ) content = file_path.read_text() # Current implementation joins with \n, but respects original trailing newline diff --git a/tests/tools/test_registry.py b/tests/tools/test_registry.py index ae124239c78..a0a9b34be34 100644 --- a/tests/tools/test_registry.py +++ b/tests/tools/test_registry.py @@ -28,7 +28,7 @@ def test_registry_initialization(self): assert len(tools) > 0, "Registry should have tools after initialization" # Check that essential tools are registered - essential_tools = {"contextmanager", "replacetext", "finished"} + essential_tools = {"contextmanager", "edittext", "finished"} for tool in essential_tools: assert tool in tools, f"Essential tool {tool} should be registered" @@ -53,18 +53,18 @@ def test_build_registry_empty_config(self): # Essential tools should always be included assert "contextmanager" in registry, "Essential tool should be included" - assert "replacetext" in registry, "Essential tool should be included" + assert "edittext" in registry, "Essential tool should be included" assert "finished" in registry, "Essential tool should be included" def test_build_registry_with_includelist(self): """Test filtering with tools_includelist""" - config = {"tools_includelist": ["contextmanager", "replacetext"]} + config = {"tools_includelist": ["contextmanager", "edittext"]} registry = ToolRegistry.build_registry(config) # Should only include tools from includelist, plus essential tools assert len(registry) == 3, "Should include 2 from list + 1 essential" assert "contextmanager" in registry - assert "replacetext" in registry + assert "edittext" in registry assert "finished" in registry # Essential assert "command" not in registry, "Should not include tools not in includelist" @@ -80,19 +80,19 @@ def test_build_registry_with_excludelist(self): def test_build_registry_exclude_essential(self): """Test that essential tools cannot be excluded""" - config = {"tools_excludelist": ["contextmanager", "replacetext", "finished", "command"]} + config = {"tools_excludelist": ["contextmanager", "edittext", "finished", "command"]} registry = ToolRegistry.build_registry(config) # Essential tools should still be included despite excludelist assert "contextmanager" in registry, "Essential tool cannot be excluded" - assert "replacetext" in registry, "Essential tool cannot be excluded" + assert "edittext" in registry, "Essential tool cannot be excluded" assert "finished" in registry, "Essential tool cannot be excluded" assert "command" not in registry, "Non-essential tool should be excluded" def test_build_registry_combined_filters(self): """Test combined filtering with includelist and excludelist""" config = { - "tools_includelist": ["contextmanager", "replacetext", "command"], + "tools_includelist": ["contextmanager", "edittext", "command"], "tools_excludelist": ["commandinteractive"], } registry = ToolRegistry.build_registry(config) @@ -100,36 +100,36 @@ def test_build_registry_combined_filters(self): # Should respect all filters assert len(registry) == 4, "Should include exactly 4 tools (3 from list + finished)" assert "contextmanager" in registry - assert "replacetext" in registry + assert "edittext" in registry assert "finished" in registry assert "command" in registry assert "commandinteractive" not in registry def test_get_filtered_tools(self): """Test get_filtered_tools method""" - config = {"tools_includelist": ["contextmanager", "replacetext"]} + config = {"tools_includelist": ["contextmanager", "edittext"]} ToolRegistry.build_registry(config) tool_names = ToolRegistry.get_registered_tools() # Should return list of tool names assert isinstance(tool_names, list) - # Should include contextmanager, replacetext, and finished (essential) + # Should include contextmanager, edittext, and finished (essential) assert len(tool_names) == 3 assert "contextmanager" in tool_names - assert "replacetext" in tool_names + assert "edittext" in tool_names assert "finished" in tool_names # Essential tool always included def test_legacy_config_names(self): """Test backward compatibility with legacy config names (whitelist/blacklist)""" config = { - "tools_whitelist": ["contextmanager", "replacetext"], + "tools_whitelist": ["contextmanager", "edittext"], "tools_blacklist": ["command"], } registry = ToolRegistry.build_registry(config) # Should work with legacy names assert "contextmanager" in registry - assert "replacetext" in registry + assert "edittext" in registry assert "command" not in registry def test_config_precedence(self): @@ -152,7 +152,7 @@ def test_config_precedence(self): def test_registry_consistency(self): """Test that registry methods return consistent results""" - config = {"tools_includelist": ["contextmanager", "replacetext"]} + config = {"tools_includelist": ["contextmanager", "edittext"]} # build_registry should return consistent results registry = ToolRegistry.build_registry(config) From 1f07e2163d2a974f8c7388fcc2158c03c7bbd0f5 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 28 Apr 2026 00:20:29 -0400 Subject: [PATCH 8/9] Add clearer instructions on how to handle truncated command files --- cecli/tools/command.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/cecli/tools/command.py b/cecli/tools/command.py index 8ddc5e2cbb5..28c1bec9ba6 100644 --- a/cecli/tools/command.py +++ b/cecli/tools/command.py @@ -255,8 +255,10 @@ async def _execute_with_timeout(cls, coder, command_string, timeout, use_pty=Fal f"[Large Response ({total_size} characters). " "Output saved to paginated files.]\n" f"File Aliases (for use with ContextManager):\n{alias_list_str}\n" - "Use the `ContextManager` tool to view these files and to remove them " - "when done reading. Do not use standard cli tools to view these files." + "Use the `ContextManager` tool to view these files." + "Do not use standard cli tools to view these files." + "Remove them from context after taking note of the relevant information " + "in the output to prevent overfilling stale context." ) # Remove from background tracking since it's done @@ -339,8 +341,10 @@ async def _execute_foreground(cls, coder, command_string): f"[Large Response ({total_size} characters). " "Output saved to paginated files.]\n" f"File Aliases (for use with ContextManager):\n{alias_list_str}\n" - "Use the `ContextManager` tool to view these files and to remove them " - "when done reading. Do not use standard cli tools to view these files." + "Use the `ContextManager` tool to view these files." + "Do not use standard cli tools to view these files." + "Remove them from context after taking note of the relevant information " + "in the output to prevent overfilling stale context." ) if tui: From cb4946d3a6f1622d494fc3b7ad7de29913f3be22 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 28 Apr 2026 00:22:41 -0400 Subject: [PATCH 9/9] #493: Decode Error in Gitignore per PR without changes to pyproject.toml --- cecli/watch.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/cecli/watch.py b/cecli/watch.py index 214de576db3..f2c77ccbf96 100644 --- a/cecli/watch.py +++ b/cecli/watch.py @@ -55,8 +55,11 @@ def load_gitignores(gitignore_paths: list[Path]) -> Optional[PathSpec]: ] # Always ignore for path in gitignore_paths: if path.exists(): - with open(path) as f: - patterns.extend(f.readlines()) + try: + with open(path, "r", encoding="utf-8", errors="ignore") as f: + patterns.extend(f.readlines()) + except Exception: + pass # Ignore files that can't be read return PathSpec.from_lines(GitWildMatchPattern, patterns) if patterns else None