Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 7 additions & 17 deletions cecli/coders/agent_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 2 additions & 3 deletions cecli/coders/base_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions cecli/helpers/plugin_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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()

Expand Down
62 changes: 43 additions & 19 deletions cecli/tools/utils/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
based on agent configuration.
"""

import traceback
from pathlib import Path
from typing import Dict, List, Optional, Set, Type

Expand All @@ -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
Expand All @@ -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)
Expand All @@ -65,18 +82,24 @@ 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":
try:
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(
Expand All @@ -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
Expand Down
12 changes: 6 additions & 6 deletions cecli/website/docs/config/agent-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion tests/basic/test_repomap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 6 additions & 6 deletions tests/tools/test_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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
Expand Down
Loading