diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index ef8970b19ef..812ffe6754b 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 = [] @@ -290,28 +295,13 @@ async def _execute_local_tool_calls(self, tool_calls_list): if norm_tool_name in ToolRegistry.get_registered_tools(): tool_module = ToolRegistry.get_tool(norm_tool_name) for params in parsed_args_list: - result = tool_module.process_response(self, params) + result = tool_module.execute(self, **params) if asyncio.iscoroutine(result): 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}'") + all_results_content.append(f"Error: Unknown local tool name '{tool_name}'") if tasks: task_results = await asyncio.gather(*tasks) all_results_content.extend(str(res) for res in task_results) diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index 5e7ae2a44e0..b2a4f58e854 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -48,7 +48,6 @@ from cecli.io import ConfirmGroup, InputOutput from cecli.linter import Linter from cecli.llm import litellm -from cecli.mcp import LocalServer from cecli.models import RETRY_TIMEOUT from cecli.reasoning_tags import ( REASONING_TAG, @@ -544,7 +543,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( @@ -2566,7 +2565,7 @@ async def _execute_tool_groups(self, tool_groups): # Execute tools for each server for server, tool_calls in tool_groups.items(): # Check if this server is an instance of LocalServer (local tools) - if isinstance(server, LocalServer): + if server.name == "Local": # Local tools - use _execute_local_tools local_responses = await self._execute_local_tools(tool_calls) all_responses[server] = local_responses 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/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