diff --git a/cecli/__init__.py b/cecli/__init__.py index 531b06e4f23..2fd5d634443 100644 --- a/cecli/__init__.py +++ b/cecli/__init__.py @@ -1,6 +1,6 @@ from packaging import version -__version__ = "0.98.0.dev" +__version__ = "0.98.2.dev" safe_version = __version__ try: diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index ef8970b19ef..fb086c5c0aa 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -43,6 +43,7 @@ class AgentCoder(Coder): def __init__(self, *args, **kwargs): self.recently_removed = {} self.tool_usage_history = [] + self.loaded_custom_tools = [] self.tool_usage_retries = 20 self.last_round_tools = [] self.tool_call_vectors = [] @@ -90,6 +91,7 @@ def __init__(self, *args, **kwargs): self.agent_config = self._get_agent_config() self._setup_agent() ToolRegistry.build_registry(agent_config=self.agent_config) + self.loaded_custom_tools = ToolRegistry.loaded_custom_tools super().__init__(*args, **kwargs) def _setup_agent(self): @@ -196,6 +198,9 @@ def _initialize_skills_manager(self, config): def show_announcements(self): super().show_announcements() + if self.loaded_custom_tools: + self.io.tool_output(f"Loaded custom tools: {', '.join(self.loaded_custom_tools)}") + skills = self.skills_manager.find_skills() if skills: skills_list = [] @@ -295,21 +300,6 @@ async def _execute_local_tool_calls(self, tool_calls_list): tasks.append(result) else: tasks.append(asyncio.to_thread(lambda: result)) - elif self.mcp_tools: - for server_name, server_tools in self.mcp_tools: - if any( - t.get("function", {}).get("name") == norm_tool_name - for t in server_tools - ): - server = self.mcp_manager.get_server(server_name) - if server: - for params in parsed_args_list: - tasks.append( - self._execute_mcp_tool(server, norm_tool_name, params) - ) - break - else: - all_results_content.append(f"Error: Unknown tool name '{tool_name}'") else: all_results_content.append(f"Error: Unknown tool name '{tool_name}'") if tasks: diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index 5e7ae2a44e0..6c107bf2f4d 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -544,7 +544,7 @@ def __init__( max_code_line_length=map_max_line_length, repo_root=self.root, use_memory_cache=repomap_in_memory, - use_enhanced_map=False if not self.args or self.args.use_enhanced_map else True, + use_enhanced_map=getattr(self.args, "use_enhanced_map", False), ) self.summarizer = summarizer or ChatSummary( @@ -3221,12 +3221,13 @@ async def show_send_output_stream(self, completion): try: func = chunk.choices[0].delta.function_call # dump(func) - for k, v in func.items(): - self.tool_reflection = True - self.io.update_spinner_suffix(v) + if func: + for k, v in func.items(): + self.tool_reflection = True + self.io.update_spinner_suffix(v) - received_content = True - self.token_profiler.on_token() + received_content = True + self.token_profiler.on_token() except AttributeError: pass diff --git a/cecli/helpers/plugin_manager.py b/cecli/helpers/plugin_manager.py index f9e9bad4380..59f76adb93d 100644 --- a/cecli/helpers/plugin_manager.py +++ b/cecli/helpers/plugin_manager.py @@ -37,6 +37,8 @@ def normalize_filename(filename: str) -> str: :param filename: Original filename :return: Normalized module name """ + if not filename: + return "" # Remove extension name = Path(filename).stem @@ -58,6 +60,8 @@ def load_module(source, module_name=None, reload=False): :param module_name: name of module to register in sys.modules :return: loaded module """ + if not source: + return None # Convert to absolute path for cache key source_path = Path(source).resolve() diff --git a/cecli/helpers/requests.py b/cecli/helpers/requests.py index f2ed199c937..9c10099240b 100644 --- a/cecli/helpers/requests.py +++ b/cecli/helpers/requests.py @@ -125,7 +125,7 @@ def flush_user_messages(): return result -def add_continue_for_no_prefill(model, messages): +def add_continue_for_no_prefill(model, messages, tools): """Add a 'Continue' user message for models that don't support assistant prefill. Args: @@ -139,20 +139,34 @@ def add_continue_for_no_prefill(model, messages): # Check if model doesn't support assistant prefill # If not, inject a dummy user message with content "Continue" # but only if the last message is not already a user message + append_message = False + if not model.info.get("supports_assistant_prefill", False): # Only add "Continue" if the last message is not a user message if not messages or messages[-1].get("role") != "user": # Add a user message with content "Continue" to the messages list - messages.append({"role": "user", "content": "Continue"}) + append_message = True + + if ( + tools + and messages + and messages[-1].get("role") == "assistant" + and messages[-1].get("prefix", False) + ): + messages[-1].pop("prefix", None) + append_message = True + + if append_message: + messages.append({"role": "user", "content": "Continue"}) return messages -def model_request_parser(model, messages): +def model_request_parser(model, messages, tools): messages = thought_signature(model, messages) messages = remove_empty_tool_calls(messages) messages = concatenate_user_messages(messages) messages = ensure_alternating_roles(messages) messages = add_reasoning_content(messages) - messages = add_continue_for_no_prefill(model, messages) + messages = add_continue_for_no_prefill(model, messages, tools) return messages diff --git a/cecli/models.py b/cecli/models.py index 3cf7bb29627..022b1723a9d 100644 --- a/cecli/models.py +++ b/cecli/models.py @@ -977,7 +977,7 @@ async def send_completion( ): if os.environ.get("CECLI_SANITY_CHECK_TURNS"): sanity_check_messages(messages) - messages = model_request_parser(self, messages) + messages = model_request_parser(self, messages, tools) if self.verbose: for message in messages: msg_role = message.get("role") @@ -1120,7 +1120,7 @@ async def simple_send_with_retries(self, messages, max_tokens=None): from cecli.exceptions import LiteLLMExceptions litellm_ex = LiteLLMExceptions() - messages = model_request_parser(self, messages) + messages = model_request_parser(self, messages, None) retry_delay = 0.125 if self.verbose: dump(messages) diff --git a/cecli/tools/finished.py b/cecli/tools/finished.py index c9d55ccb09e..ff1a8ef7518 100644 --- a/cecli/tools/finished.py +++ b/cecli/tools/finished.py @@ -3,6 +3,7 @@ class Tool(BaseTool): NORM_NAME = "finished" + TRACK_INVOCATIONS = False SCHEMA = { "type": "function", "function": { diff --git a/cecli/tools/show_context.py b/cecli/tools/show_context.py index 204cd94edc7..65eee3b115b 100644 --- a/cecli/tools/show_context.py +++ b/cecli/tools/show_context.py @@ -277,3 +277,7 @@ def execute(cls, coder, show, **kwargs): except Exception as e: # Handle unexpected errors during processing return handle_tool_error(coder, tool_name, e) + + @classmethod + def on_duplicate_request(cls, coder, **kwargs): + coder.edit_allowed = True diff --git a/cecli/tools/update_todo_list.py b/cecli/tools/update_todo_list.py index 27da0793e48..223c85256a1 100644 --- a/cecli/tools/update_todo_list.py +++ b/cecli/tools/update_todo_list.py @@ -76,6 +76,13 @@ def execute(cls, coder, tasks, append=False, change_id=None, dry_run=False, **kw remaining_tasks = [] for task_item in tasks: + if not isinstance(task_item, dict): + task_item = { + "task": str(task_item), + "done": False, + "current": False, + } + if task_item.get("done", False): done_tasks.append(f"✓ {task_item['task']}") else: @@ -197,6 +204,13 @@ def format_output(cls, coder, mcp_server, tool_response): remaining_tasks = [] for task_item in tasks: + if not isinstance(task_item, dict): + task_item = { + "task": str(task_item), + "done": False, + "current": False, + } + if task_item.get("done", False): done_tasks.append(f"✓ {task_item['task']}") else: diff --git a/cecli/tools/utils/base_tool.py b/cecli/tools/utils/base_tool.py index e801d31373d..6fc59f888d0 100644 --- a/cecli/tools/utils/base_tool.py +++ b/cecli/tools/utils/base_tool.py @@ -81,6 +81,7 @@ def process_response(cls, coder, params): f"Tool '{tool_name}' has been called with identical parameters recently. " "This request is denied to prevent repeated operations." ) + cls.on_duplicate_request(coder, **params) return handle_tool_error(coder, tool_name, ValueError(error_msg)) # Add current invocation to history (keeping only last 3) @@ -99,3 +100,7 @@ def process_response(cls, coder, params): @classmethod def format_output(cls, coder, mcp_server, tool_response): print_tool_response(coder=coder, mcp_server=mcp_server, tool_response=tool_response) + + @classmethod + def on_duplicate_request(cls, coder, **kwargs): + pass diff --git a/cecli/tools/utils/registry.py b/cecli/tools/utils/registry.py index 27ad9e4c15e..8f694334635 100644 --- a/cecli/tools/utils/registry.py +++ b/cecli/tools/utils/registry.py @@ -6,6 +6,7 @@ based on agent configuration. """ +import traceback from pathlib import Path from typing import Dict, List, Optional, Set, Type @@ -19,11 +20,25 @@ class ToolRegistry: _tools: Dict[str, Type] = {} # normalized name -> Tool class _essential_tools: Set[str] = {"contextmanager", "replacetext", "finished"} _registry: Dict[str, Type] = {} # cached filtered registry + loaded_custom_tools: List[str] = [] @classmethod def register(cls, tool_class): - """Register a tool class.""" - name = tool_class.NORM_NAME + """Register a tool class using the name from its SCHEMA.""" + name = None + if hasattr(tool_class, "SCHEMA"): + try: + name = tool_class.SCHEMA.get("function", {}).get("name", "").lower() + except Exception: + pass + + if not name and hasattr(tool_class, "NORM_NAME"): + name = tool_class.NORM_NAME + + if not name: + # Unable to determine a name, can't register + return + cls._tools[name] = tool_class @classmethod @@ -46,13 +61,15 @@ def build_registry(cls, agent_config: Optional[Dict] = None) -> Dict[str, Type]: tools_includelist/tools_excludelist keys Returns: - Dictionary mapping normalized tool names to tool classes + A dictionary mapping normalized tool names to tool classes. + Custom loaded tools are stored in `ToolRegistry.loaded_custom_tools`. """ if agent_config is None: agent_config = {} # Load tools from tool_paths if specified tools_paths = agent_config.get("tools_paths", agent_config.get("tool_paths", [])) + loaded_custom_tools = [] for tool_path in tools_paths: path = Path(tool_path) @@ -65,9 +82,12 @@ def build_registry(cls, agent_config: Optional[Dict] = None) -> Dict[str, Type]: # Check if module has a Tool class if hasattr(module, "Tool"): cls.register(module.Tool) + if module.Tool.NORM_NAME: + loaded_custom_tools.append(module.Tool.NORM_NAME) except Exception as e: # Log error but continue with other files print(f"Error loading tool from {py_file}: {e}") + print(traceback.format_exc()) else: # If it's a file, try to load it directly if path.exists() and path.suffix == ".py": @@ -75,8 +95,11 @@ def build_registry(cls, agent_config: Optional[Dict] = None) -> Dict[str, Type]: module = plugin_manager.load_module(str(path)) if hasattr(module, "Tool"): cls.register(module.Tool) + if module.Tool.NORM_NAME: + loaded_custom_tools.append(module.Tool.NORM_NAME) except Exception as e: print(f"Error loading tool from {path}: {e}") + print(traceback.format_exc()) # Get include/exclude lists from config tools_includelist = agent_config.get( @@ -86,28 +109,29 @@ def build_registry(cls, agent_config: Optional[Dict] = None) -> Dict[str, Type]: "tools_excludelist", agent_config.get("tools_blacklist", []) ) - registry = {} - - for tool_name, tool_class in cls._tools.items(): - should_include = True - - # Apply include list if specified - if tools_includelist: - should_include = tool_name in tools_includelist + # Start with a base set of tools + if tools_includelist: + # If includelist is provided, start with only those tools + working_set = set(tools_includelist) + else: + # Otherwise, start with all registered tools + working_set = set(cls._tools.keys()) - # Essential tools are always included - if tool_name in cls._essential_tools: - should_include = True + # Add essential tools, they can't be removed by the excludelist + working_set.update(cls._essential_tools) - # Apply exclude list (unless essential) - if tool_name in tools_excludelist and tool_name not in cls._essential_tools: - should_include = False + # Remove tools from the excludelist, but keep essential ones + if tools_excludelist: + for tool_name in tools_excludelist: + if tool_name in working_set and tool_name not in cls._essential_tools: + working_set.remove(tool_name) - if should_include: - registry[tool_name] = tool_class + # Build the final registry from the working set + registry = {name: cls._tools[name] for name in working_set if name in cls._tools} # Store the built registry in the class attribute cls._registry = registry + cls.loaded_custom_tools = loaded_custom_tools return registry @classmethod diff --git a/cecli/website/docs/config/agent-mode.md b/cecli/website/docs/config/agent-mode.md index f7a5e2e308a..45ed44276e3 100644 --- a/cecli/website/docs/config/agent-mode.md +++ b/cecli/website/docs/config/agent-mode.md @@ -160,7 +160,7 @@ agent-config: # Tool configuration tools_includelist: [contextmanager", "replacetext", "finished"] # Optional: Whitelist of tools tools_excludelist: ["command", "commandinteractive"] # Optional: Blacklist of tools - tool_paths: ["./custom-tools", "~/my-tools"] # Optional: Directories or files containing custom tools + tools_paths: ["./custom-tools", "~/my-tools"] # Optional: Directories or files containing custom tools # Context blocks configuration include_context_blocks: ["todo_list", "git_status"] # Optional: Context blocks to include @@ -184,7 +184,7 @@ agent-config: - **`skip_cli_confirmations`**: YOLO mode, be brave and let the LLM cook, can also use the option `yolo` (default: False) - **`tools_includelist`**: Array of tool names to allow (only these tools will be available) - **`tools_excludelist`**: Array of tool names to exclude (these tools will be disabled) -- **`tool_paths`**: Array of directories or Python files containing custom tools to load +- **`tools_paths`**: Array of directories or Python files containing custom tools to load - **`include_context_blocks`**: Array of context block names to include (overrides default set) - **`exclude_context_blocks`**: Array of context block names to exclude from default set @@ -241,14 +241,14 @@ class Tool(BaseTool): return f"Tool executed with parameter: {parameter_name}" ``` -To load custom tools, specify the `tool_paths` configuration option in your agent config: +To load custom tools, specify the `tools_paths` configuration option in your agent config: ```yaml agent-config: - tool_paths: ["./custom-tools", "~/my-tools"] + tools_paths: ["./custom-tools", "~/my-tools"] ``` -The `tool_paths` can include: +The `tools_paths` can include: - **Directories**: All `.py` files in the directory will be scanned for `Tool` classes - **Individual Python files**: Specific tool files can be loaded directly @@ -288,7 +288,7 @@ agent-config: # Tool configuration tools_includelist: ["contextmanager", "replacetext", "finished"] # Optional: Whitelist of tools tools_excludelist: ["command", "commandinteractive"] # Optional: Blacklist of tools - tool_paths: ["./custom-tools", "~/my-tools"] # Optional: Directories or files containing custom tools + tools_paths: ["./custom-tools", "~/my-tools"] # Optional: Directories or files containing custom tools # Context blocks configuration include_context_blocks: ["todo_list", "git_status"] # Optional: Context blocks to include diff --git a/tests/basic/test_repomap.py b/tests/basic/test_repomap.py index 0b6543c64e0..5ab7e56cf55 100644 --- a/tests/basic/test_repomap.py +++ b/tests/basic/test_repomap.py @@ -577,7 +577,7 @@ def _test_language_repo_map(self, lang, key, symbol): f.write(content) io = InputOutput() - repo_map = RepoMap(main_model=self.GPT35, io=io) + repo_map = RepoMap(main_model=self.GPT35, io=io, use_enhanced_map=True) other_files = [test_file] result = repo_map.get_repo_map([], other_files) dump(lang) diff --git a/tests/tools/test_registry.py b/tests/tools/test_registry.py index b77f3d711ca..ae124239c78 100644 --- a/tests/tools/test_registry.py +++ b/tests/tools/test_registry.py @@ -58,14 +58,14 @@ def test_build_registry_empty_config(self): def test_build_registry_with_includelist(self): """Test filtering with tools_includelist""" - config = {"tools_includelist": ["contextmanager", "replacetext", "finished"]} + config = {"tools_includelist": ["contextmanager", "replacetext"]} registry = ToolRegistry.build_registry(config) - # Should only include tools in the includelist - assert len(registry) == 3, "Should only include tools from includelist" + # 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 "finished" in registry + assert "finished" in registry # Essential assert "command" not in registry, "Should not include tools not in includelist" def test_build_registry_with_excludelist(self): @@ -92,13 +92,13 @@ def test_build_registry_exclude_essential(self): def test_build_registry_combined_filters(self): """Test combined filtering with includelist and excludelist""" config = { - "tools_includelist": ["contextmanager", "replacetext", "finished", "command"], + "tools_includelist": ["contextmanager", "replacetext", "command"], "tools_excludelist": ["commandinteractive"], } registry = ToolRegistry.build_registry(config) # Should respect all filters - assert len(registry) == 4, "Should include exactly 4 tools" + assert len(registry) == 4, "Should include exactly 4 tools (3 from list + finished)" assert "contextmanager" in registry assert "replacetext" in registry assert "finished" in registry