mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
## Description `exclude_tools` only matched tool names exactly, so users could not exclude families of tools (for example all `mcp__*`). This adds glob-pattern support via a shared `is_tool_excluded` helper used by both the content router and the OpenAI handler, keeping exact/case-insensitive matching intact. Closes #870 ## Type of Change - [x] New feature (non-breaking change that adds functionality) ## Changes Made - `headroom/config.py`: added `is_tool_excluded(name, exclude_tools)` helper that keeps exact/case-insensitive matching and adds `fnmatch` glob support. - `headroom/transforms/content_router.py` and `headroom/proxy/handlers/openai.py`: routed tool-exclusion checks through the shared helper. - `headroom/proxy/server.py`: documented glob support in the `--exclude-tools` CLI help and `_parse_exclude_tools` docstring. - `tests/test_transforms/test_content_router.py`: added `test_glob_exclude_tools` and `test_is_tool_excluded_helper`. ## Testing - [x] Unit tests pass (`pytest`) - [x] New tests added for new functionality ### Test Output ```text $ pytest tests/test_transforms/test_content_router.py -q 53 passed $ pytest tests/ -k "exclude or config" -q 59 passed ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.12, pytest-asyncio 1.4.0 - Exact command / steps: Ran the content-router suite and the exclude/config-focused tests after adding the helper and glob support. - Observed result: 53 content-router tests pass (including the two new glob tests) and 59 exclude/config tests pass; glob patterns like `mcp__*` now exclude matching tools while exact names still work. - Not tested: Did not exercise glob exclusion against a live MCP server end to end. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
14e8dc4c84
commit
a2159c0b66
5 changed files with 87 additions and 5 deletions
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import InitVar, dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
|
@ -224,6 +226,28 @@ DEFAULT_EXCLUDE_TOOLS: frozenset[str] = frozenset(
|
|||
}
|
||||
)
|
||||
|
||||
|
||||
def is_tool_excluded(name: str, exclude_tools: Iterable[str]) -> bool:
|
||||
"""Return True if ``name`` matches the tool-exclusion set.
|
||||
|
||||
Plain entries match by exact (case-insensitive) name, so the common case
|
||||
stays a set lookup. Entries containing a glob metacharacter (``*``, ``?`` or
|
||||
``[``) are matched with :func:`fnmatch.fnmatchcase`, letting a single pattern
|
||||
such as ``mcp__*`` cover every tool an MCP server exposes without listing
|
||||
each name (issue #870).
|
||||
"""
|
||||
if not exclude_tools:
|
||||
return False
|
||||
if name in exclude_tools or name.lower() in exclude_tools:
|
||||
return True
|
||||
lname = name.lower()
|
||||
return any(
|
||||
fnmatch.fnmatchcase(lname, pat.lower())
|
||||
for pat in exclude_tools
|
||||
if "*" in pat or "?" in pat or "[" in pat
|
||||
)
|
||||
|
||||
|
||||
# Tool names recognized as Read/Edit/Write for lifecycle tracking
|
||||
_READ_TOOL_NAMES: frozenset[str] = frozenset({"Read", "read"})
|
||||
_EDIT_TOOL_NAMES: frozenset[str] = frozenset({"Edit", "edit"})
|
||||
|
|
|
|||
|
|
@ -790,7 +790,7 @@ class OpenAIHandlerMixin:
|
|||
# mirroring ContentRouter's policy. exclude_tools already contains both
|
||||
# original and lowercased name variants (see _parse_exclude_tools), but
|
||||
# we also test the lowercased name defensively for case-insensitivity.
|
||||
from headroom.config import DEFAULT_EXCLUDE_TOOLS
|
||||
from headroom.config import DEFAULT_EXCLUDE_TOOLS, is_tool_excluded
|
||||
|
||||
router_exclude_tools = getattr(router.config, "exclude_tools", None)
|
||||
effective_exclude_tools = (
|
||||
|
|
@ -799,7 +799,7 @@ class OpenAIHandlerMixin:
|
|||
excluded_call_ids: set[str] = {
|
||||
call_id
|
||||
for call_id, fn_name in function_name_by_call_id.items()
|
||||
if fn_name in effective_exclude_tools or fn_name.lower() in effective_exclude_tools
|
||||
if is_tool_excluded(fn_name, effective_exclude_tools)
|
||||
}
|
||||
|
||||
timing_sink: dict[str, float] = timing if timing is not None else {}
|
||||
|
|
|
|||
|
|
@ -3994,7 +3994,8 @@ def _parse_exclude_tools(cli_excludes: str | None) -> set[str]:
|
|||
(e.g. "WebSearch,WebFetch"). Each name is added in both original and
|
||||
lowercase form for case-insensitive matching, mirroring
|
||||
DEFAULT_EXCLUDE_TOOLS. Unset/empty -> empty set (DEFAULT_EXCLUDE_TOOLS
|
||||
used unchanged).
|
||||
used unchanged). Entries may contain glob patterns (e.g. "mcp__*"); see
|
||||
config.is_tool_excluded for the matching semantics.
|
||||
"""
|
||||
raw = ",".join(s for s in (cli_excludes, os.environ.get("HEADROOM_EXCLUDE_TOOLS")) if s)
|
||||
names: set[str] = set()
|
||||
|
|
@ -4205,6 +4206,7 @@ if __name__ == "__main__":
|
|||
default=None,
|
||||
help="Comma-separated tool names whose output is never compressed, "
|
||||
"merged with the built-in defaults (e.g., WebSearch,WebFetch). "
|
||||
"Entries may use glob patterns, e.g. 'mcp__*' to exclude every MCP tool. "
|
||||
"Also settable via HEADROOM_EXCLUDE_TOOLS env var.",
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -47,7 +47,12 @@ from dataclasses import dataclass, field
|
|||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from ..config import DEFAULT_EXCLUDE_TOOLS, ReadLifecycleConfig, TransformResult
|
||||
from ..config import (
|
||||
DEFAULT_EXCLUDE_TOOLS,
|
||||
ReadLifecycleConfig,
|
||||
TransformResult,
|
||||
is_tool_excluded,
|
||||
)
|
||||
from ..tokenizer import Tokenizer
|
||||
from .base import Transform
|
||||
from .content_detector import ContentType, DetectionResult
|
||||
|
|
@ -2321,7 +2326,9 @@ class ContentRouter(Transform):
|
|||
else DEFAULT_EXCLUDE_TOOLS
|
||||
)
|
||||
excluded_tool_ids = {
|
||||
tool_id for tool_id, name in tool_name_map.items() if name in exclude_tools
|
||||
tool_id
|
||||
for tool_id, name in tool_name_map.items()
|
||||
if is_tool_excluded(name, exclude_tools)
|
||||
}
|
||||
|
||||
# --- Adaptive parameters based on context pressure ---
|
||||
|
|
|
|||
|
|
@ -681,6 +681,55 @@ class TestExcludeTools:
|
|||
assert result.messages[1]["content"] == messages[1]["content"]
|
||||
assert "router:excluded:tool" in result.transforms_applied
|
||||
|
||||
def test_glob_exclude_tools(self, tokenizer):
|
||||
"""Glob patterns in exclude_tools match by prefix (issue #870)."""
|
||||
config = ContentRouterConfig(
|
||||
min_section_tokens=10,
|
||||
exclude_tools={"mcp__*"}, # One pattern excludes every MCP tool
|
||||
)
|
||||
router = ContentRouter(config)
|
||||
|
||||
messages = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_mcp_1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "mcp__build123d__measure",
|
||||
"arguments": "{}",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_mcp_1",
|
||||
"content": generate_json_data(50),
|
||||
},
|
||||
]
|
||||
|
||||
result = router.apply(messages, tokenizer)
|
||||
|
||||
# The MCP tool result matched the glob and was left unchanged.
|
||||
assert result.messages[1]["content"] == messages[1]["content"]
|
||||
assert "router:excluded:tool" in result.transforms_applied
|
||||
|
||||
def test_is_tool_excluded_helper(self):
|
||||
"""is_tool_excluded: exact (case-insensitive) and glob matching."""
|
||||
from headroom.config import is_tool_excluded
|
||||
|
||||
# Glob entry covers a whole MCP server; unrelated tools are untouched.
|
||||
assert is_tool_excluded("mcp__build123d__measure", {"mcp__*"})
|
||||
assert not is_tool_excluded("Bash", {"mcp__*"})
|
||||
# Plain entries keep exact, case-insensitive membership.
|
||||
assert is_tool_excluded("Read", {"read"})
|
||||
assert is_tool_excluded("MCP__X", {"mcp__*"})
|
||||
# Empty set never excludes.
|
||||
assert not is_tool_excluded("Read", set())
|
||||
|
||||
def test_non_excluded_tools_are_compressed(self, tokenizer):
|
||||
"""Tools not in exclude_tools set are still compressed."""
|
||||
config = ContentRouterConfig(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue