feat(skill): add directory and search skills
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
"""ASV benchmarks for skill-level search and directory tools.
|
||||
|
||||
Measures the performance of:
|
||||
- ListDir with filters
|
||||
- Glob pattern matching
|
||||
- Grep regex searching
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from cleveragents.skills.builtins.search_ops import (
|
||||
register_skill_search_tools,
|
||||
)
|
||||
from cleveragents.tool.registry import ToolRegistry
|
||||
from cleveragents.tool.runner import ToolRunner
|
||||
except ModuleNotFoundError:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
from cleveragents.skills.builtins.search_ops import (
|
||||
register_skill_search_tools,
|
||||
)
|
||||
from cleveragents.tool.registry import ToolRegistry
|
||||
from cleveragents.tool.runner import ToolRunner
|
||||
|
||||
|
||||
class SkillSearchToolSuite:
|
||||
"""Benchmark skill-level search tool operations."""
|
||||
|
||||
def setup(self) -> None:
|
||||
self.sandbox = tempfile.mkdtemp()
|
||||
self.registry = ToolRegistry()
|
||||
register_skill_search_tools(self.registry)
|
||||
self.runner = ToolRunner(self.registry)
|
||||
# Create test file tree
|
||||
sub = Path(self.sandbox) / "src"
|
||||
sub.mkdir()
|
||||
for i in range(20):
|
||||
(sub / f"module_{i}.py").write_text(
|
||||
f"def function_{i}():\n return {i}\n"
|
||||
)
|
||||
docs = Path(self.sandbox) / "docs"
|
||||
docs.mkdir()
|
||||
for i in range(5):
|
||||
(docs / f"guide_{i}.md").write_text(f"# Guide {i}\nContent for guide {i}\n")
|
||||
|
||||
def teardown(self) -> None:
|
||||
shutil.rmtree(self.sandbox, ignore_errors=True)
|
||||
|
||||
def time_list_dir(self) -> None:
|
||||
self.runner.execute(
|
||||
"skill/list-dir",
|
||||
{"path": "src", "sandbox_root": self.sandbox},
|
||||
)
|
||||
|
||||
def time_glob_py_files(self) -> None:
|
||||
self.runner.execute(
|
||||
"skill/glob",
|
||||
{
|
||||
"path": ".",
|
||||
"pattern": "**/*.py",
|
||||
"sandbox_root": self.sandbox,
|
||||
},
|
||||
)
|
||||
|
||||
def time_grep_function(self) -> None:
|
||||
self.runner.execute(
|
||||
"skill/grep",
|
||||
{
|
||||
"path": ".",
|
||||
"pattern": "def function",
|
||||
"sandbox_root": self.sandbox,
|
||||
},
|
||||
)
|
||||
|
||||
def time_grep_with_context(self) -> None:
|
||||
self.runner.execute(
|
||||
"skill/grep",
|
||||
{
|
||||
"path": ".",
|
||||
"pattern": "def function",
|
||||
"context_lines": 2,
|
||||
"sandbox_root": self.sandbox,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class SkillSearchRegistrationSuite:
|
||||
"""Benchmark skill search tool registration."""
|
||||
|
||||
def time_register_skill_search_tools(self) -> None:
|
||||
registry = ToolRegistry()
|
||||
register_skill_search_tools(registry)
|
||||
@@ -0,0 +1,99 @@
|
||||
# Search and Directory Skills
|
||||
|
||||
Skill-level directory listing and search tools for agents. All tools
|
||||
enforce path traversal guards and provide deterministic, sorted output.
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Capability | Description |
|
||||
|-----------------|-------------|-----------------------------------------------------|
|
||||
| `skill/list-dir`| `read_only` | List directory with filters, limits, type filtering |
|
||||
| `skill/glob` | `read_only` | Match files by glob with include/exclude filters |
|
||||
| `skill/grep` | `read_only` | Search contents by regex with context lines |
|
||||
|
||||
## ListDir
|
||||
|
||||
Lists directory contents with:
|
||||
|
||||
- **Ignore patterns**: Glob patterns to exclude entries.
|
||||
- **Type filter**: Show only files, only directories, or all.
|
||||
- **Size limit**: Cap the number of entries returned.
|
||||
|
||||
### Example
|
||||
|
||||
```python
|
||||
from cleveragents.skills.builtins.search_ops import register_skill_search_tools
|
||||
from cleveragents.tool.registry import ToolRegistry
|
||||
from cleveragents.tool.runner import ToolRunner
|
||||
|
||||
registry = ToolRegistry()
|
||||
register_skill_search_tools(registry)
|
||||
runner = ToolRunner(registry)
|
||||
|
||||
result = runner.execute("skill/list-dir", {
|
||||
"path": "src",
|
||||
"type_filter": "files",
|
||||
"ignore_patterns": ["__pycache__", "*.pyc"],
|
||||
"sandbox_root": "/project",
|
||||
})
|
||||
for entry in result.output["entries"]:
|
||||
print(f"{entry['name']} ({entry['size']} bytes)")
|
||||
```
|
||||
|
||||
## Glob
|
||||
|
||||
Matches files recursively by glob pattern with:
|
||||
|
||||
- **Exclude patterns**: Filter out unwanted matches.
|
||||
- **Include patterns**: Restrict matches to specific names.
|
||||
- **Deterministic sorting**: Results are always sorted by path.
|
||||
- **Size limit**: Cap the number of matches returned.
|
||||
|
||||
### Example
|
||||
|
||||
```python
|
||||
result = runner.execute("skill/glob", {
|
||||
"path": ".",
|
||||
"pattern": "**/*.py",
|
||||
"exclude_patterns": ["test_*"],
|
||||
"sandbox_root": "/project",
|
||||
})
|
||||
```
|
||||
|
||||
## Grep
|
||||
|
||||
Searches file contents with regex:
|
||||
|
||||
- **Context lines**: Include N lines before and after each match.
|
||||
- **Binary file skipping**: Binary files are automatically skipped.
|
||||
- **Per-file match cap**: Limit matches per file (default: 50).
|
||||
- **Total match limit**: Overall cap on matches (default: 500).
|
||||
- **Regex error handling**: Invalid patterns produce clear error messages.
|
||||
|
||||
### Example
|
||||
|
||||
```python
|
||||
result = runner.execute("skill/grep", {
|
||||
"path": "src",
|
||||
"pattern": "def\\s+\\w+",
|
||||
"include": "*.py",
|
||||
"context_lines": 2,
|
||||
"sandbox_root": "/project",
|
||||
})
|
||||
for match in result.output["matches"]:
|
||||
print(f"{match['file']}:{match['line']}: {match['content']}")
|
||||
```
|
||||
|
||||
### Regex Error Handling
|
||||
|
||||
When an invalid regex pattern is provided, Grep returns a clear error:
|
||||
|
||||
```
|
||||
ValueError: Invalid regex pattern '[unclosed': unterminated character set
|
||||
```
|
||||
|
||||
### Binary File Behavior
|
||||
|
||||
Grep automatically detects and skips binary files using the same
|
||||
heuristics as the ReadFile tool (signature detection + control character
|
||||
analysis). Binary files are silently excluded from results.
|
||||
@@ -0,0 +1,213 @@
|
||||
Feature: Skill Search and Directory Tools
|
||||
As a developer
|
||||
I want skill-level directory listing and search tools
|
||||
So that agents can list directories, match files by glob, and
|
||||
search file contents with regex
|
||||
|
||||
# ---- ListDir ----
|
||||
|
||||
Scenario: ListDir lists directory contents
|
||||
Given a skill search sandbox directory
|
||||
And a search sandbox file "a.txt" with content "a"
|
||||
And a search sandbox file "b.py" with content "b"
|
||||
When I list-dir "." in the search sandbox
|
||||
Then the search tool result should be successful
|
||||
And the search output "total" should be greater than or equal to 2
|
||||
And the search output "truncated" should be boolean false
|
||||
|
||||
Scenario: ListDir with type filter for files only
|
||||
Given a skill search sandbox directory
|
||||
And a search sandbox file "file.txt" with content "content"
|
||||
And a search sandbox subdirectory "subdir"
|
||||
When I list-dir-typed "." with type_filter "files"
|
||||
Then the search tool result should be successful
|
||||
And all search entries should not be directories
|
||||
|
||||
Scenario: ListDir with type filter for dirs only
|
||||
Given a skill search sandbox directory
|
||||
And a search sandbox file "file.txt" with content "content"
|
||||
And a search sandbox subdirectory "subdir"
|
||||
When I list-dir-typed "." with type_filter "dirs"
|
||||
Then the search tool result should be successful
|
||||
And all search entries should be directories
|
||||
|
||||
Scenario: ListDir with ignore patterns
|
||||
Given a skill search sandbox directory
|
||||
And a search sandbox file "keep.txt" with content "keep"
|
||||
And a search sandbox file "ignore.log" with content "ignore"
|
||||
When I list-dir-ignoring "." ignoring "*.log"
|
||||
Then the search tool result should be successful
|
||||
And no search entry should have name "ignore.log"
|
||||
|
||||
Scenario: ListDir with size limit
|
||||
Given a skill search sandbox directory
|
||||
And a search sandbox file "1.txt" with content "one"
|
||||
And a search sandbox file "2.txt" with content "two"
|
||||
And a search sandbox file "3.txt" with content "three"
|
||||
When I list-dir-limited "." with limit 2
|
||||
Then the search tool result should be successful
|
||||
And the search output "truncated" should be boolean true
|
||||
And the search output "total" should equal 2
|
||||
|
||||
Scenario: ListDir on non-directory raises error
|
||||
Given a skill search sandbox directory
|
||||
And a search sandbox file "notadir.txt" with content "x"
|
||||
When I list-dir "notadir.txt" in the search sandbox
|
||||
Then the search tool result should not be successful
|
||||
And the search tool error should mention "not a directory"
|
||||
|
||||
Scenario: ListDir with invalid type_filter raises error
|
||||
Given a skill search sandbox directory
|
||||
When I list-dir-typed "." with type_filter "invalid"
|
||||
Then the search tool result should not be successful
|
||||
And the search tool error should mention "Invalid type_filter"
|
||||
|
||||
# ---- Glob ----
|
||||
|
||||
Scenario: Glob matches files by pattern
|
||||
Given a skill search sandbox directory
|
||||
And a search sandbox file "main.py" with content "code"
|
||||
And a search sandbox file "test.py" with content "test"
|
||||
And a search sandbox file "readme.md" with content "docs"
|
||||
When I glob-match "." for "*.py"
|
||||
Then the search tool result should be successful
|
||||
And the search glob total should be 2
|
||||
|
||||
Scenario: Glob with exclude patterns
|
||||
Given a skill search sandbox directory
|
||||
And a search sandbox file "main.py" with content "code"
|
||||
And a search sandbox file "test.py" with content "test"
|
||||
When I glob-exclude "." for "*.py" excluding "test*"
|
||||
Then the search tool result should be successful
|
||||
And the search glob total should be 1
|
||||
And no search match should have name "test.py"
|
||||
|
||||
Scenario: Glob with include filter
|
||||
Given a skill search sandbox directory
|
||||
And a search sandbox file "main.py" with content "code"
|
||||
And a search sandbox file "test.py" with content "test"
|
||||
When I glob-include "." for "*" including "*.py"
|
||||
Then the search tool result should be successful
|
||||
And the search glob total should be at least 2
|
||||
|
||||
Scenario: Glob returns deterministic sorted results
|
||||
Given a skill search sandbox directory
|
||||
And a search sandbox file "c.txt" with content "c"
|
||||
And a search sandbox file "a.txt" with content "a"
|
||||
And a search sandbox file "b.txt" with content "b"
|
||||
When I glob-match "." for "*.txt"
|
||||
Then the search tool result should be successful
|
||||
And the search glob results should be sorted by name
|
||||
|
||||
Scenario: Glob on non-directory raises error
|
||||
Given a skill search sandbox directory
|
||||
And a search sandbox file "notadir.txt" with content "x"
|
||||
When I glob-match "notadir.txt" for "*.txt"
|
||||
Then the search tool result should not be successful
|
||||
And the search tool error should mention "not a directory"
|
||||
|
||||
Scenario: Glob with limit truncation
|
||||
Given a skill search sandbox directory
|
||||
And a search sandbox file "1.txt" with content "one"
|
||||
And a search sandbox file "2.txt" with content "two"
|
||||
And a search sandbox file "3.txt" with content "three"
|
||||
When I glob-limited "." for "*.txt" with limit 2
|
||||
Then the search tool result should be successful
|
||||
And the search output "truncated" should be boolean true
|
||||
|
||||
# ---- Grep ----
|
||||
|
||||
Scenario: Grep finds matching lines
|
||||
Given a skill search sandbox directory
|
||||
And a search sandbox file "haystack.txt" with content "needle in a haystack"
|
||||
When I grep-search "." for "needle"
|
||||
Then the search tool result should be successful
|
||||
And the search grep total should be at least 1
|
||||
|
||||
Scenario: Grep with context lines
|
||||
Given a skill search sandbox directory
|
||||
And a search sandbox file "context.txt" with lines "before,match,after"
|
||||
When I grep-context "." for "match" with context 1
|
||||
Then the search tool result should be successful
|
||||
And the search grep first match should have context_before
|
||||
And the search grep first match should have context_after
|
||||
|
||||
Scenario: Grep skips binary files
|
||||
Given a skill search sandbox directory
|
||||
And a search sandbox binary file "data.bin"
|
||||
And a search sandbox file "text.txt" with content "findme"
|
||||
When I grep-search "." for "findme"
|
||||
Then the search tool result should be successful
|
||||
And the search grep matches should not reference "data.bin"
|
||||
|
||||
Scenario: Grep with per-file match cap
|
||||
Given a skill search sandbox directory
|
||||
And a search sandbox file "many.txt" with lines "match,match,match,match,match"
|
||||
When I grep-capped "." for "match" with per_file_cap 2
|
||||
Then the search tool result should be successful
|
||||
And the search grep total should equal 2
|
||||
|
||||
Scenario: Grep with total match limit
|
||||
Given a skill search sandbox directory
|
||||
And a search sandbox file "m1.txt" with lines "hit,hit,hit"
|
||||
And a search sandbox file "m2.txt" with lines "hit,hit,hit"
|
||||
When I grep-maxed "." for "hit" with max_matches 3
|
||||
Then the search tool result should be successful
|
||||
And the search grep total should equal 3
|
||||
And the search output "truncated" should be boolean true
|
||||
|
||||
Scenario: Grep with invalid regex raises error
|
||||
Given a skill search sandbox directory
|
||||
When I grep-search "." for "[invalid"
|
||||
Then the search tool result should not be successful
|
||||
And the search tool error should mention "Invalid regex"
|
||||
|
||||
Scenario: Grep on non-directory raises error
|
||||
Given a skill search sandbox directory
|
||||
And a search sandbox file "notadir.txt" with content "x"
|
||||
When I grep-search "notadir.txt" for "x"
|
||||
Then the search tool result should not be successful
|
||||
And the search tool error should mention "not a directory"
|
||||
|
||||
# ---- Path Traversal ----
|
||||
|
||||
Scenario: Path traversal in list-dir is rejected
|
||||
Given a skill search sandbox directory
|
||||
When I list-dir "../../etc" in the search sandbox
|
||||
Then the search tool result should not be successful
|
||||
And the search tool error should mention "traversal"
|
||||
|
||||
Scenario: Path traversal in glob is rejected
|
||||
Given a skill search sandbox directory
|
||||
When I glob-match "../../etc" for "*"
|
||||
Then the search tool result should not be successful
|
||||
And the search tool error should mention "traversal"
|
||||
|
||||
Scenario: Path traversal in grep is rejected
|
||||
Given a skill search sandbox directory
|
||||
When I grep-search "../../etc" for "root"
|
||||
Then the search tool result should not be successful
|
||||
And the search tool error should mention "traversal"
|
||||
|
||||
# ---- Read-Only Capability ----
|
||||
|
||||
Scenario: ListDir has read_only capability
|
||||
Given a skill search tools registry
|
||||
Then the search tool "skill/list-dir" should have read_only capability
|
||||
|
||||
Scenario: Glob has read_only capability
|
||||
Given a skill search tools registry
|
||||
Then the search tool "skill/glob" should have read_only capability
|
||||
|
||||
Scenario: Grep has read_only capability
|
||||
Given a skill search tools registry
|
||||
Then the search tool "skill/grep" should have read_only capability
|
||||
|
||||
# ---- Registration ----
|
||||
|
||||
Scenario: Register all skill search tools
|
||||
Given a skill search tools registry
|
||||
Then the search registry should contain 3 tools
|
||||
And the search registry should contain tool "skill/list-dir"
|
||||
And the search registry should contain tool "skill/glob"
|
||||
And the search registry should contain tool "skill/grep"
|
||||
@@ -0,0 +1,344 @@
|
||||
"""Step definitions for the Skill Search and Directory Tools feature."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from behave import given, then, when
|
||||
|
||||
from cleveragents.skills.builtins.search_ops import register_skill_search_tools
|
||||
from cleveragents.tool.registry import ToolRegistry
|
||||
from cleveragents.tool.runner import ToolRunner
|
||||
|
||||
__all__: list[str] = []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _run_search_tool(context: Any, tool_name: str, inputs: dict[str, Any]) -> None:
|
||||
"""Execute a skill search tool through the runner."""
|
||||
registry = ToolRegistry()
|
||||
register_skill_search_tools(registry)
|
||||
runner = ToolRunner(registry)
|
||||
inputs["sandbox_root"] = context.search_sandbox_dir
|
||||
context.search_tool_result = runner.execute(tool_name, inputs)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Givens
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a skill search sandbox directory")
|
||||
def step_given_search_sandbox(context: Any) -> None:
|
||||
context.search_sandbox_dir = tempfile.mkdtemp()
|
||||
context._cleanup_handlers.append(
|
||||
lambda: shutil.rmtree(context.search_sandbox_dir, ignore_errors=True)
|
||||
)
|
||||
|
||||
|
||||
@given('a search sandbox file "{name}" with content "{content}"')
|
||||
def step_given_search_file(context: Any, name: str, content: str) -> None:
|
||||
path = Path(context.search_sandbox_dir) / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content)
|
||||
|
||||
|
||||
@given('a search sandbox file "{name}" with lines "{csv_lines}"')
|
||||
def step_given_search_file_lines(context: Any, name: str, csv_lines: str) -> None:
|
||||
lines = csv_lines.split(",")
|
||||
path = Path(context.search_sandbox_dir) / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("\n".join(lines) + "\n")
|
||||
|
||||
|
||||
@given('a search sandbox subdirectory "{name}"')
|
||||
def step_given_search_subdir(context: Any, name: str) -> None:
|
||||
path = Path(context.search_sandbox_dir) / name
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
@given('a search sandbox binary file "{name}"')
|
||||
def step_given_search_binary(context: Any, name: str) -> None:
|
||||
path = Path(context.search_sandbox_dir) / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)
|
||||
|
||||
|
||||
@given("a skill search tools registry")
|
||||
def step_given_search_registry(context: Any) -> None:
|
||||
context.search_registry = ToolRegistry()
|
||||
register_skill_search_tools(context.search_registry)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Whens - ListDir
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('I list-dir "{path}" in the search sandbox')
|
||||
def step_when_list_dir(context: Any, path: str) -> None:
|
||||
_run_search_tool(context, "skill/list-dir", {"path": path})
|
||||
|
||||
|
||||
@when('I list-dir-typed "{path}" with type_filter "{type_filter}"')
|
||||
def step_when_list_dir_type(context: Any, path: str, type_filter: str) -> None:
|
||||
_run_search_tool(
|
||||
context,
|
||||
"skill/list-dir",
|
||||
{"path": path, "type_filter": type_filter},
|
||||
)
|
||||
|
||||
|
||||
@when('I list-dir-ignoring "{path}" ignoring "{pattern}"')
|
||||
def step_when_list_dir_ignore(context: Any, path: str, pattern: str) -> None:
|
||||
_run_search_tool(
|
||||
context,
|
||||
"skill/list-dir",
|
||||
{"path": path, "ignore_patterns": [pattern]},
|
||||
)
|
||||
|
||||
|
||||
@when('I list-dir-limited "{path}" with limit {limit:d}')
|
||||
def step_when_list_dir_limit(context: Any, path: str, limit: int) -> None:
|
||||
_run_search_tool(
|
||||
context,
|
||||
"skill/list-dir",
|
||||
{"path": path, "limit": limit},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Whens - Glob
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('I glob-match "{path}" for "{pattern}"')
|
||||
def step_when_glob_basic(context: Any, path: str, pattern: str) -> None:
|
||||
_run_search_tool(context, "skill/glob", {"path": path, "pattern": pattern})
|
||||
|
||||
|
||||
@when('I glob-exclude "{path}" for "{pattern}" excluding "{exclude}"')
|
||||
def step_when_glob_excl(context: Any, path: str, pattern: str, exclude: str) -> None:
|
||||
_run_search_tool(
|
||||
context,
|
||||
"skill/glob",
|
||||
{
|
||||
"path": path,
|
||||
"pattern": pattern,
|
||||
"exclude_patterns": [exclude],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@when('I glob-include "{path}" for "{pattern}" including "{include}"')
|
||||
def step_when_glob_incl(context: Any, path: str, pattern: str, include: str) -> None:
|
||||
_run_search_tool(
|
||||
context,
|
||||
"skill/glob",
|
||||
{
|
||||
"path": path,
|
||||
"pattern": pattern,
|
||||
"include_patterns": [include],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@when('I glob-limited "{path}" for "{pattern}" with limit {limit:d}')
|
||||
def step_when_glob_lim(context: Any, path: str, pattern: str, limit: int) -> None:
|
||||
_run_search_tool(
|
||||
context,
|
||||
"skill/glob",
|
||||
{"path": path, "pattern": pattern, "limit": limit},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Whens - Grep
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('I grep-search "{path}" for "{pattern}"')
|
||||
def step_when_grep_basic(context: Any, path: str, pattern: str) -> None:
|
||||
_run_search_tool(context, "skill/grep", {"path": path, "pattern": pattern})
|
||||
|
||||
|
||||
@when('I grep-context "{path}" for "{pattern}" with context {ctx:d}')
|
||||
def step_when_grep_ctx(context: Any, path: str, pattern: str, ctx: int) -> None:
|
||||
_run_search_tool(
|
||||
context,
|
||||
"skill/grep",
|
||||
{"path": path, "pattern": pattern, "context_lines": ctx},
|
||||
)
|
||||
|
||||
|
||||
@when('I grep-capped "{path}" for "{pattern}" with per_file_cap {cap:d}')
|
||||
def step_when_grep_cap(context: Any, path: str, pattern: str, cap: int) -> None:
|
||||
_run_search_tool(
|
||||
context,
|
||||
"skill/grep",
|
||||
{"path": path, "pattern": pattern, "per_file_cap": cap},
|
||||
)
|
||||
|
||||
|
||||
@when('I grep-maxed "{path}" for "{pattern}" with max_matches {max_m:d}')
|
||||
def step_when_grep_max(context: Any, path: str, pattern: str, max_m: int) -> None:
|
||||
_run_search_tool(
|
||||
context,
|
||||
"skill/grep",
|
||||
{"path": path, "pattern": pattern, "max_matches": max_m},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Thens
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("the search tool result should be successful")
|
||||
def step_then_search_success(context: Any) -> None:
|
||||
result = context.search_tool_result
|
||||
assert result.success, f"Tool failed: {result.error}"
|
||||
|
||||
|
||||
@then("the search tool result should not be successful")
|
||||
def step_then_search_fail(context: Any) -> None:
|
||||
result = context.search_tool_result
|
||||
assert not result.success, "Expected failure but tool succeeded"
|
||||
|
||||
|
||||
@then('the search output "{key}" should be greater than or equal to {value:d}')
|
||||
def step_then_search_output_gte(context: Any, key: str, value: int) -> None:
|
||||
assert context.search_tool_result.output[key] >= value, (
|
||||
f"Expected {key}>={value}, got {context.search_tool_result.output[key]}"
|
||||
)
|
||||
|
||||
|
||||
@then('the search output "{key}" should be boolean true')
|
||||
def step_then_search_output_true(context: Any, key: str) -> None:
|
||||
assert context.search_tool_result.output[key] is True
|
||||
|
||||
|
||||
@then('the search output "{key}" should be boolean false')
|
||||
def step_then_search_output_false(context: Any, key: str) -> None:
|
||||
assert context.search_tool_result.output[key] is False
|
||||
|
||||
|
||||
@then('the search output "{key}" should equal {value:d}')
|
||||
def step_then_search_output_eq(context: Any, key: str, value: int) -> None:
|
||||
assert context.search_tool_result.output[key] == value, (
|
||||
f"Expected {key}={value}, got {context.search_tool_result.output[key]}"
|
||||
)
|
||||
|
||||
|
||||
@then("all search entries should not be directories")
|
||||
def step_then_no_dirs(context: Any) -> None:
|
||||
entries = context.search_tool_result.output["entries"]
|
||||
for e in entries:
|
||||
assert not e["is_dir"], f"{e['name']} is a directory"
|
||||
|
||||
|
||||
@then("all search entries should be directories")
|
||||
def step_then_all_dirs(context: Any) -> None:
|
||||
entries = context.search_tool_result.output["entries"]
|
||||
assert len(entries) > 0, "Expected at least one directory entry"
|
||||
for e in entries:
|
||||
assert e["is_dir"], f"{e['name']} is not a directory"
|
||||
|
||||
|
||||
@then('no search entry should have name "{name}"')
|
||||
def step_then_no_entry_name(context: Any, name: str) -> None:
|
||||
entries = context.search_tool_result.output["entries"]
|
||||
names = [e["name"] for e in entries]
|
||||
assert name not in names, f"Found unwanted entry: {name}"
|
||||
|
||||
|
||||
@then('the search tool error should mention "{text}"')
|
||||
def step_then_search_error(context: Any, text: str) -> None:
|
||||
error = context.search_tool_result.error or ""
|
||||
assert text.lower() in error.lower(), f"Expected '{text}' in error: {error}"
|
||||
|
||||
|
||||
@then("the search glob total should be {count:d}")
|
||||
def step_then_glob_total(context: Any, count: int) -> None:
|
||||
total = context.search_tool_result.output["total"]
|
||||
assert total == count, f"Expected {count} matches, got {total}"
|
||||
|
||||
|
||||
@then("the search glob total should be at least {count:d}")
|
||||
def step_then_glob_total_min(context: Any, count: int) -> None:
|
||||
total = context.search_tool_result.output["total"]
|
||||
assert total >= count, f"Expected >={count} matches, got {total}"
|
||||
|
||||
|
||||
@then('no search match should have name "{name}"')
|
||||
def step_then_no_match_name(context: Any, name: str) -> None:
|
||||
matches = context.search_tool_result.output["matches"]
|
||||
names = [m["name"] for m in matches]
|
||||
assert name not in names, f"Found unwanted match: {name}"
|
||||
|
||||
|
||||
@then("the search glob results should be sorted by name")
|
||||
def step_then_glob_sorted(context: Any) -> None:
|
||||
matches = context.search_tool_result.output["matches"]
|
||||
names = [m["name"] for m in matches]
|
||||
assert names == sorted(names), f"Not sorted: {names}"
|
||||
|
||||
|
||||
@then("the search grep total should be at least {count:d}")
|
||||
def step_then_grep_total_min(context: Any, count: int) -> None:
|
||||
total = context.search_tool_result.output["total"]
|
||||
assert total >= count, f"Expected >={count} matches, got {total}"
|
||||
|
||||
|
||||
@then("the search grep total should equal {count:d}")
|
||||
def step_then_grep_total_eq(context: Any, count: int) -> None:
|
||||
total = context.search_tool_result.output["total"]
|
||||
assert total == count, f"Expected {count} matches, got {total}"
|
||||
|
||||
|
||||
@then("the search grep first match should have context_before")
|
||||
def step_then_grep_context_before(context: Any) -> None:
|
||||
matches = context.search_tool_result.output["matches"]
|
||||
assert len(matches) > 0, "No matches found"
|
||||
assert "context_before" in matches[0], "Missing context_before"
|
||||
|
||||
|
||||
@then("the search grep first match should have context_after")
|
||||
def step_then_grep_context_after(context: Any) -> None:
|
||||
matches = context.search_tool_result.output["matches"]
|
||||
assert len(matches) > 0, "No matches found"
|
||||
assert "context_after" in matches[0], "Missing context_after"
|
||||
|
||||
|
||||
@then('the search grep matches should not reference "{name}"')
|
||||
def step_then_grep_no_file(context: Any, name: str) -> None:
|
||||
matches = context.search_tool_result.output["matches"]
|
||||
for m in matches:
|
||||
assert name not in m["file"], f"Found match in excluded file: {m['file']}"
|
||||
|
||||
|
||||
@then('the search tool "{name}" should have read_only capability')
|
||||
def step_then_search_tool_readonly(context: Any, name: str) -> None:
|
||||
spec = context.search_registry.get(name)
|
||||
assert spec is not None, f"Tool {name} not found"
|
||||
assert spec.capabilities.read_only, f"{name} should be read_only"
|
||||
|
||||
|
||||
@then("the search registry should contain {count:d} tools")
|
||||
def step_then_search_registry_count(context: Any, count: int) -> None:
|
||||
tools = context.search_registry.list_tools()
|
||||
assert len(tools) == count, f"Expected {count} tools, got {len(tools)}"
|
||||
|
||||
|
||||
@then('the search registry should contain tool "{name}"')
|
||||
def step_then_search_registry_has(context: Any, name: str) -> None:
|
||||
assert context.search_registry.get(name) is not None, (
|
||||
f"Tool '{name}' not found in registry"
|
||||
)
|
||||
+21
-21
@@ -3651,27 +3651,27 @@ No standalone Q0-Advanced commits planned. Advanced QA enhancements are bundled
|
||||
- [X] Git [Jeff]: `git commit -m "feat(skill): add file operation skills"`
|
||||
- [X] Git [Jeff]: `git push -u origin feature/m3-skill-file-search`
|
||||
- [X] Forgejo PR [Jeff]: Open PR from `feature/m3-skill-file-search` to `master` with a suitable and thorough description
|
||||
- [ ] **COMMIT (Owner: Jeff | Group: C4.search | Branch: feature/m3-skill-file-search | Planned: Day 18 | Expected: Day 20) - Commit message: "feat(skill): add directory and search skills"**
|
||||
- [ ] Git [Jeff]: `git checkout master`
|
||||
- [ ] Git [Jeff]: `git pull origin master`
|
||||
- [ ] Git [Jeff]: `git checkout -b feature/m3-skill-file-search`
|
||||
- [ ] Code [Jeff]: Implement ListDir, Glob, and Grep tools with ignore patterns and size limits.
|
||||
- [ ] Code [Jeff]: Register tools in Tool Registry with resource bindings and sandbox awareness.
|
||||
- [ ] Code [Jeff]: Enforce include/exclude glob filters from project context policies.
|
||||
- [ ] Code [Jeff]: Skip binary files and cap per-file match counts to keep output deterministic.
|
||||
- [ ] Code [Jeff]: Add regex compile error handling for Grep with explicit error output.
|
||||
- [ ] Code [Jeff]: Add sorting for glob/list outputs to ensure deterministic ordering.
|
||||
- [ ] Docs [Jeff]: Add `docs/reference/skills_search.md` with examples.
|
||||
- [ ] Docs [Jeff]: Document grep regex behavior and binary file skipping.
|
||||
- [ ] Tests (Behave) [Jeff]: Add `features/skill_search.feature` for listing/globbing/searching.
|
||||
- [ ] Tests (Robot) [Jeff]: Add `robot/skill_search.robot` for search integration.
|
||||
- [ ] Tests (ASV) [Jeff]: Add `benchmarks/search_tool_bench.py` for search performance.
|
||||
- [ ] Quality [Jeff]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve code coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%.
|
||||
- [ ] Quality [Jeff]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes across **entire** code base, do not ignore any failure even if it seems unrelated to this commit, fix it.
|
||||
- [ ] Git [Jeff]: Perform an appropriate `git add` command to add all the files that should be part of this commit to the git index
|
||||
- [ ] Git [Jeff]: `git commit -m "feat(skill): add directory and search skills"`
|
||||
- [ ] Git [Jeff]: `git push -u origin feature/m3-skill-file-search`
|
||||
- [ ] Forgejo PR [Jeff]: Open PR from `feature/m3-skill-file-search` to `master` with a suitable and thorough description
|
||||
- [X] **COMMIT (Owner: Jeff | Group: C4.search | Branch: feature/m3-skill-file-search | Planned: Day 18 | Expected: Day 20) - Commit message: "feat(skill): add directory and search skills"**
|
||||
- [X] Git [Jeff]: `git checkout master`
|
||||
- [X] Git [Jeff]: `git pull origin master`
|
||||
- [X] Git [Jeff]: `git checkout -b feature/m3-skill-file-search`
|
||||
- [X] Code [Jeff]: Implement ListDir, Glob, and Grep tools with ignore patterns and size limits.
|
||||
- [X] Code [Jeff]: Register tools in Tool Registry with resource bindings and sandbox awareness.
|
||||
- [X] Code [Jeff]: Enforce include/exclude glob filters from project context policies.
|
||||
- [X] Code [Jeff]: Skip binary files and cap per-file match counts to keep output deterministic.
|
||||
- [X] Code [Jeff]: Add regex compile error handling for Grep with explicit error output.
|
||||
- [X] Code [Jeff]: Add sorting for glob/list outputs to ensure deterministic ordering.
|
||||
- [X] Docs [Jeff]: Add `docs/reference/skills_search.md` with examples.
|
||||
- [X] Docs [Jeff]: Document grep regex behavior and binary file skipping.
|
||||
- [X] Tests (Behave) [Jeff]: Add `features/skill_search.feature` for listing/globbing/searching.
|
||||
- [X] Tests (Robot) [Jeff]: Add `robot/skill_search.robot` for search integration.
|
||||
- [X] Tests (ASV) [Jeff]: Add `benchmarks/search_tool_bench.py` for search performance.
|
||||
- [X] Quality [Jeff]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve code coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%.
|
||||
- [X] Quality [Jeff]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes across **entire** code base, do not ignore any failure even if it seems unrelated to this commit, fix it.
|
||||
- [X] Git [Jeff]: Perform an appropriate `git add` command to add all the files that should be part of this commit to the git index
|
||||
- [X] Git [Jeff]: `git commit -m "feat(skill): add directory and search skills"`
|
||||
- [X] Git [Jeff]: `git push -u origin feature/m3-skill-file-search`
|
||||
- [X] Forgejo PR [Jeff]: Open PR from `feature/m3-skill-file-search` to `master` with a suitable and thorough description
|
||||
- [ ] **COMMIT (Owner: Luis | Group: C4.git | Branch: feature/m3-skill-git | Planned: Day 19 | Expected: Day 20) - Commit message: "feat(skill): add git operation skills"**
|
||||
- [ ] Git [Luis]: `git checkout master`
|
||||
- [ ] Git [Luis]: `git pull origin master`
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Helper script for Robot Framework skill search tools smoke tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# Ensure src is importable when run from workspace root
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
from cleveragents.skills.builtins.search_ops import register_skill_search_tools
|
||||
from cleveragents.tool.registry import ToolRegistry
|
||||
from cleveragents.tool.runner import ToolRunner
|
||||
|
||||
|
||||
def _make_runner(sandbox: str) -> ToolRunner:
|
||||
registry = ToolRegistry()
|
||||
register_skill_search_tools(registry)
|
||||
return ToolRunner(registry)
|
||||
|
||||
|
||||
def test_listdir() -> None:
|
||||
sandbox = tempfile.mkdtemp()
|
||||
try:
|
||||
(Path(sandbox) / "a.txt").write_text("a")
|
||||
(Path(sandbox) / "b.txt").write_text("b")
|
||||
runner = _make_runner(sandbox)
|
||||
result = runner.execute(
|
||||
"skill/list-dir",
|
||||
{"path": ".", "sandbox_root": sandbox},
|
||||
)
|
||||
assert result.success, f"Failed: {result.error}"
|
||||
assert result.output["total"] >= 2
|
||||
print("skill-listdir-ok")
|
||||
finally:
|
||||
shutil.rmtree(sandbox, ignore_errors=True)
|
||||
|
||||
|
||||
def test_glob() -> None:
|
||||
sandbox = tempfile.mkdtemp()
|
||||
try:
|
||||
(Path(sandbox) / "main.py").write_text("code")
|
||||
(Path(sandbox) / "test.py").write_text("test")
|
||||
(Path(sandbox) / "readme.md").write_text("docs")
|
||||
runner = _make_runner(sandbox)
|
||||
result = runner.execute(
|
||||
"skill/glob",
|
||||
{"path": ".", "pattern": "*.py", "sandbox_root": sandbox},
|
||||
)
|
||||
assert result.success, f"Failed: {result.error}"
|
||||
assert result.output["total"] == 2
|
||||
print("skill-glob-ok")
|
||||
finally:
|
||||
shutil.rmtree(sandbox, ignore_errors=True)
|
||||
|
||||
|
||||
def test_grep() -> None:
|
||||
sandbox = tempfile.mkdtemp()
|
||||
try:
|
||||
(Path(sandbox) / "data.txt").write_text("needle in haystack\n")
|
||||
runner = _make_runner(sandbox)
|
||||
result = runner.execute(
|
||||
"skill/grep",
|
||||
{"path": ".", "pattern": "needle", "sandbox_root": sandbox},
|
||||
)
|
||||
assert result.success, f"Failed: {result.error}"
|
||||
assert result.output["total"] >= 1
|
||||
print("skill-grep-ok")
|
||||
finally:
|
||||
shutil.rmtree(sandbox, ignore_errors=True)
|
||||
|
||||
|
||||
def test_traversal() -> None:
|
||||
sandbox = tempfile.mkdtemp()
|
||||
try:
|
||||
runner = _make_runner(sandbox)
|
||||
result = runner.execute(
|
||||
"skill/list-dir",
|
||||
{"path": "../../etc", "sandbox_root": sandbox},
|
||||
)
|
||||
assert not result.success
|
||||
assert "traversal" in (result.error or "").lower()
|
||||
print("skill-search-traversal-ok")
|
||||
finally:
|
||||
shutil.rmtree(sandbox, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cmd = sys.argv[1] if len(sys.argv) > 1 else "listdir"
|
||||
dispatch: dict[str, Any] = {
|
||||
"listdir": test_listdir,
|
||||
"glob": test_glob,
|
||||
"grep": test_grep,
|
||||
"traversal": test_traversal,
|
||||
}
|
||||
fn = dispatch.get(cmd)
|
||||
if fn:
|
||||
fn()
|
||||
else:
|
||||
print(f"Unknown command: {cmd}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,33 @@
|
||||
*** Settings ***
|
||||
Documentation Smoke tests for skill-level search and directory tools
|
||||
Resource ${CURDIR}/common.resource
|
||||
Suite Setup Setup Test Environment
|
||||
Suite Teardown Cleanup Test Environment
|
||||
|
||||
*** Variables ***
|
||||
${HELPER_SCRIPT} robot/helper_skill_search.py
|
||||
|
||||
*** Test Cases ***
|
||||
Skill List Dir
|
||||
[Documentation] List directory using skill/list-dir
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} listdir cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} skill-listdir-ok
|
||||
|
||||
Skill Glob
|
||||
[Documentation] Glob files using skill/glob
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} glob cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} skill-glob-ok
|
||||
|
||||
Skill Grep
|
||||
[Documentation] Search files using skill/grep
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} grep cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} skill-grep-ok
|
||||
|
||||
Skill Search Path Traversal
|
||||
[Documentation] Path traversal should be blocked
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} traversal cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} skill-search-traversal-ok
|
||||
@@ -0,0 +1,441 @@
|
||||
"""Skill-level directory listing and search tools for CleverAgents.
|
||||
|
||||
Provides tools that integrate with the skill framework:
|
||||
|
||||
- **ListDir**: List directory contents with ignore patterns, size limits,
|
||||
and type filters (files/dirs/all).
|
||||
- **Glob**: Match files by glob pattern with include/exclude filters and
|
||||
deterministic sorting.
|
||||
- **Grep**: Search file contents by regex with line numbers, context
|
||||
lines, binary file skipping, per-file match caps, and regex compile
|
||||
error handling.
|
||||
|
||||
## Path Traversal Guards
|
||||
|
||||
All tools reject paths that escape the sandbox root directory.
|
||||
|
||||
## Registration
|
||||
|
||||
```python
|
||||
from cleveragents.skills.builtins.search_ops import register_skill_search_tools
|
||||
from cleveragents.tool.registry import ToolRegistry
|
||||
|
||||
registry = ToolRegistry()
|
||||
register_skill_search_tools(registry)
|
||||
```
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from cleveragents.domain.models.core.tool import ToolCapability
|
||||
from cleveragents.skills.builtins.file_ops import (
|
||||
_is_binary,
|
||||
validate_sandbox_path,
|
||||
)
|
||||
from cleveragents.tool.registry import ToolRegistry
|
||||
from cleveragents.tool.runtime import ToolSpec
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: Default maximum number of entries for ListDir.
|
||||
DEFAULT_LIST_LIMIT: int = 1000
|
||||
|
||||
#: Default maximum matches per file for Grep.
|
||||
DEFAULT_PER_FILE_CAP: int = 50
|
||||
|
||||
#: Default maximum total matches for Grep.
|
||||
DEFAULT_MAX_MATCHES: int = 500
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _handle_skill_list_dir(inputs: dict[str, Any]) -> dict[str, Any]:
|
||||
"""List directory contents with filters and limits."""
|
||||
path = validate_sandbox_path(inputs["path"], inputs.get("sandbox_root"))
|
||||
ignore_patterns: list[str] = inputs.get("ignore_patterns", [])
|
||||
type_filter: str = inputs.get("type_filter", "all")
|
||||
limit: int = inputs.get("limit", DEFAULT_LIST_LIMIT)
|
||||
|
||||
if not path.is_dir():
|
||||
raise ValueError(f"Path is not a directory: {inputs['path']}")
|
||||
|
||||
if type_filter not in ("all", "files", "dirs"):
|
||||
raise ValueError(
|
||||
f"Invalid type_filter '{type_filter}'. Must be 'all', 'files', or 'dirs'"
|
||||
)
|
||||
|
||||
entries: list[dict[str, Any]] = []
|
||||
for item in sorted(path.iterdir()):
|
||||
# Apply ignore patterns
|
||||
if any(fnmatch.fnmatch(item.name, p) for p in ignore_patterns):
|
||||
continue
|
||||
|
||||
is_dir = item.is_dir()
|
||||
# Apply type filter
|
||||
if type_filter == "files" and is_dir:
|
||||
continue
|
||||
if type_filter == "dirs" and not is_dir:
|
||||
continue
|
||||
|
||||
try:
|
||||
stat_info = item.stat()
|
||||
size = stat_info.st_size
|
||||
except OSError:
|
||||
size = 0
|
||||
|
||||
entries.append(
|
||||
{
|
||||
"name": item.name,
|
||||
"path": str(item),
|
||||
"size": size,
|
||||
"is_dir": is_dir,
|
||||
}
|
||||
)
|
||||
if len(entries) >= limit:
|
||||
break
|
||||
|
||||
return {
|
||||
"entries": entries,
|
||||
"total": len(entries),
|
||||
"truncated": len(entries) >= limit,
|
||||
}
|
||||
|
||||
|
||||
def _handle_skill_glob(inputs: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Match files by glob pattern with include/exclude filters."""
|
||||
path = validate_sandbox_path(inputs["path"], inputs.get("sandbox_root"))
|
||||
pattern: str = inputs["pattern"]
|
||||
exclude_patterns: list[str] = inputs.get("exclude_patterns", [])
|
||||
include_patterns: list[str] = inputs.get("include_patterns", [])
|
||||
limit: int = inputs.get("limit", DEFAULT_LIST_LIMIT)
|
||||
|
||||
if not path.is_dir():
|
||||
raise ValueError(f"Path is not a directory: {inputs['path']}")
|
||||
|
||||
raw_matches = sorted(path.rglob(pattern))
|
||||
matches: list[dict[str, Any]] = []
|
||||
|
||||
for item in raw_matches:
|
||||
name = item.name
|
||||
# Apply exclude patterns
|
||||
if any(fnmatch.fnmatch(name, p) for p in exclude_patterns):
|
||||
continue
|
||||
# Apply include patterns (if specified, only include matching)
|
||||
if include_patterns and not any(
|
||||
fnmatch.fnmatch(name, p) for p in include_patterns
|
||||
):
|
||||
continue
|
||||
|
||||
try:
|
||||
stat_info = item.stat()
|
||||
size = stat_info.st_size
|
||||
except OSError:
|
||||
size = 0
|
||||
|
||||
matches.append(
|
||||
{
|
||||
"name": name,
|
||||
"path": str(item),
|
||||
"size": size,
|
||||
"is_dir": item.is_dir(),
|
||||
}
|
||||
)
|
||||
if len(matches) >= limit:
|
||||
break
|
||||
|
||||
return {
|
||||
"matches": matches,
|
||||
"total": len(matches),
|
||||
"truncated": len(matches) >= limit,
|
||||
}
|
||||
|
||||
|
||||
def _handle_skill_grep(inputs: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Search file contents by regex with context lines."""
|
||||
path = validate_sandbox_path(inputs["path"], inputs.get("sandbox_root"))
|
||||
pattern: str = inputs["pattern"]
|
||||
include: str = inputs.get("include", "*")
|
||||
context_lines: int = inputs.get("context_lines", 0)
|
||||
per_file_cap: int = inputs.get("per_file_cap", DEFAULT_PER_FILE_CAP)
|
||||
max_matches: int = inputs.get("max_matches", DEFAULT_MAX_MATCHES)
|
||||
|
||||
if not path.is_dir():
|
||||
raise ValueError(f"Path is not a directory: {inputs['path']}")
|
||||
|
||||
# Compile regex with error handling
|
||||
try:
|
||||
compiled = re.compile(pattern)
|
||||
except re.error as exc:
|
||||
raise ValueError(f"Invalid regex pattern '{pattern}': {exc}") from exc
|
||||
|
||||
matches: list[dict[str, Any]] = []
|
||||
|
||||
for file_path in sorted(path.rglob(include)):
|
||||
if file_path.is_dir():
|
||||
continue
|
||||
|
||||
# Skip binary files
|
||||
if _is_binary(file_path):
|
||||
continue
|
||||
|
||||
try:
|
||||
content = file_path.read_text(errors="replace")
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
file_lines = content.splitlines()
|
||||
file_match_count = 0
|
||||
|
||||
for line_no, line in enumerate(file_lines, start=1):
|
||||
if compiled.search(line):
|
||||
match_entry: dict[str, Any] = {
|
||||
"file": str(file_path),
|
||||
"line": line_no,
|
||||
"content": line,
|
||||
}
|
||||
|
||||
# Add context lines if requested
|
||||
if context_lines > 0:
|
||||
start = max(0, line_no - 1 - context_lines)
|
||||
end = min(len(file_lines), line_no + context_lines)
|
||||
match_entry["context_before"] = file_lines[start : line_no - 1]
|
||||
match_entry["context_after"] = file_lines[line_no:end]
|
||||
|
||||
matches.append(match_entry)
|
||||
file_match_count += 1
|
||||
|
||||
if file_match_count >= per_file_cap:
|
||||
break
|
||||
if len(matches) >= max_matches:
|
||||
return {
|
||||
"matches": matches,
|
||||
"total": len(matches),
|
||||
"truncated": True,
|
||||
}
|
||||
|
||||
return {
|
||||
"matches": matches,
|
||||
"total": len(matches),
|
||||
"truncated": False,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool specs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SKILL_LIST_DIR_SPEC = ToolSpec(
|
||||
name="skill/list-dir",
|
||||
description=(
|
||||
"List directory contents with ignore patterns, size limits, and type filters"
|
||||
),
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Directory path to list",
|
||||
},
|
||||
"ignore_patterns": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"default": [],
|
||||
"description": "Glob patterns to ignore",
|
||||
},
|
||||
"type_filter": {
|
||||
"type": "string",
|
||||
"default": "all",
|
||||
"description": "Filter: all, files, or dirs",
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"default": 1000,
|
||||
"description": "Maximum entries to return",
|
||||
},
|
||||
"sandbox_root": {
|
||||
"type": "string",
|
||||
"description": "Sandbox root directory",
|
||||
},
|
||||
},
|
||||
"required": ["path"],
|
||||
},
|
||||
output_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entries": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"path": {"type": "string"},
|
||||
"size": {"type": "integer"},
|
||||
"is_dir": {"type": "boolean"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"total": {"type": "integer"},
|
||||
"truncated": {"type": "boolean"},
|
||||
},
|
||||
},
|
||||
capabilities=ToolCapability(read_only=True),
|
||||
handler=_handle_skill_list_dir,
|
||||
)
|
||||
|
||||
SKILL_GLOB_SPEC = ToolSpec(
|
||||
name="skill/glob",
|
||||
description=(
|
||||
"Match files by glob pattern with include/exclude filters "
|
||||
"and deterministic sorting"
|
||||
),
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Directory path to search",
|
||||
},
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"description": "Glob pattern to match",
|
||||
},
|
||||
"exclude_patterns": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"default": [],
|
||||
"description": "Glob patterns to exclude",
|
||||
},
|
||||
"include_patterns": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"default": [],
|
||||
"description": "Glob patterns to include (filter)",
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"default": 1000,
|
||||
"description": "Maximum matches to return",
|
||||
},
|
||||
"sandbox_root": {
|
||||
"type": "string",
|
||||
"description": "Sandbox root directory",
|
||||
},
|
||||
},
|
||||
"required": ["path", "pattern"],
|
||||
},
|
||||
output_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"matches": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"path": {"type": "string"},
|
||||
"size": {"type": "integer"},
|
||||
"is_dir": {"type": "boolean"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"total": {"type": "integer"},
|
||||
"truncated": {"type": "boolean"},
|
||||
},
|
||||
},
|
||||
capabilities=ToolCapability(read_only=True),
|
||||
handler=_handle_skill_glob,
|
||||
)
|
||||
|
||||
SKILL_GREP_SPEC = ToolSpec(
|
||||
name="skill/grep",
|
||||
description=(
|
||||
"Search file contents by regex with line numbers, context lines, "
|
||||
"and binary file skipping"
|
||||
),
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Directory path to search",
|
||||
},
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"description": "Regex pattern to search for",
|
||||
},
|
||||
"include": {
|
||||
"type": "string",
|
||||
"default": "*",
|
||||
"description": "Glob pattern to filter files",
|
||||
},
|
||||
"context_lines": {
|
||||
"type": "integer",
|
||||
"default": 0,
|
||||
"description": "Number of context lines around matches",
|
||||
},
|
||||
"per_file_cap": {
|
||||
"type": "integer",
|
||||
"default": 50,
|
||||
"description": "Maximum matches per file",
|
||||
},
|
||||
"max_matches": {
|
||||
"type": "integer",
|
||||
"default": 500,
|
||||
"description": "Maximum total matches",
|
||||
},
|
||||
"sandbox_root": {
|
||||
"type": "string",
|
||||
"description": "Sandbox root directory",
|
||||
},
|
||||
},
|
||||
"required": ["path", "pattern"],
|
||||
},
|
||||
output_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"matches": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file": {"type": "string"},
|
||||
"line": {"type": "integer"},
|
||||
"content": {"type": "string"},
|
||||
"context_before": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
},
|
||||
"context_after": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"total": {"type": "integer"},
|
||||
"truncated": {"type": "boolean"},
|
||||
},
|
||||
},
|
||||
capabilities=ToolCapability(read_only=True),
|
||||
handler=_handle_skill_grep,
|
||||
)
|
||||
|
||||
ALL_SKILL_SEARCH_TOOLS: list[ToolSpec] = [
|
||||
SKILL_LIST_DIR_SPEC,
|
||||
SKILL_GLOB_SPEC,
|
||||
SKILL_GREP_SPEC,
|
||||
]
|
||||
|
||||
|
||||
def register_skill_search_tools(registry: ToolRegistry) -> None:
|
||||
"""Register all skill-level search tools into *registry*."""
|
||||
for spec in ALL_SKILL_SEARCH_TOOLS:
|
||||
registry.register(spec)
|
||||
Reference in New Issue
Block a user