Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
25c2382
fix: Allow both tool_paths and tools_paths in agent config
szmania Apr 1, 2026
90602d6
refactor: Rename tool_paths to tools_paths in agent config
szmania Apr 1, 2026
369d6b4
fix: Standardize tool_paths to tools_paths in agent config
szmania Apr 1, 2026
316341d
feat: Announce loaded custom tools in TUI
szmania Apr 1, 2026
6c4a95d
feat: Announce loaded custom tools in TUI
szmania Apr 1, 2026
fcc0428
fix: Add checks for None or empty strings in plugin manager
szmania Apr 1, 2026
fe8887d
fix: Add check for NORM_NAME in tool registry
szmania Apr 1, 2026
322349c
fix: Correctly call tool execute method in agent coder
szmania Apr 2, 2026
4169623
fix: Correct local tool dispatch logic in AgentCoder
szmania Apr 2, 2026
2c87290
fix: Improve error logging in tool registry and base coder
szmania Apr 2, 2026
420a4f1
fix: Use schema name for tool registration
szmania Apr 2, 2026
9980fac
fix: Adjust tool registry tests for tuple return value
szmania Apr 3, 2026
3c13289
fix: Resolve test failures in tool registry and repomap
szmania Apr 3, 2026
867fee4
fix: Enable enhanced map for C# repo map test
szmania Apr 3, 2026
3dbf615
cli-4: fixed merge conflicts
szmania Apr 3, 2026
cb951e7
fix: Adjust tool registry to return dict and fix local tool dispatch
szmania Apr 3, 2026
b1293d9
Bump Version
Apr 4, 2026
fb1fd52
Finished tool should be exempt from invocation tracking
Apr 4, 2026
6b06813
fix: Address tool registry logic and flake8 error
szmania Apr 4, 2026
e293426
test
szmania Apr 4, 2026
1bb4064
fix: Remove unused variable from ToolRegistry tests
szmania Apr 4, 2026
2346ed5
Fix prefix = True with tool calls error
Apr 5, 2026
3cd4bea
Allow edits for show_context even on duplicate calls so editing gets …
Apr 5, 2026
164ffc4
Update model_request_parser for simple send to include tools argument
Apr 5, 2026
d7e1ab6
Handle cases where model returns raw strings for todo list
Apr 5, 2026
2bc7a46
Merge pull request #476 from szmania/cli-4-fix-tool-paths
dwash96 Apr 5, 2026
1896707
Small changes
Apr 5, 2026
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
2 changes: 1 addition & 1 deletion cecli/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from packaging import version

__version__ = "0.98.0.dev"
__version__ = "0.98.2.dev"
safe_version = __version__

try:
Expand Down
20 changes: 5 additions & 15 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 @@ -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:
Expand Down
13 changes: 7 additions & 6 deletions cecli/coders/base_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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

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
22 changes: 18 additions & 4 deletions cecli/helpers/requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
4 changes: 2 additions & 2 deletions cecli/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions cecli/tools/finished.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

class Tool(BaseTool):
NORM_NAME = "finished"
TRACK_INVOCATIONS = False
SCHEMA = {
"type": "function",
"function": {
Expand Down
4 changes: 4 additions & 0 deletions cecli/tools/show_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
14 changes: 14 additions & 0 deletions cecli/tools/update_todo_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions cecli/tools/utils/base_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
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
Loading
Loading