Add approval continuation and multi-step tools
This commit is contained in:
@@ -3,6 +3,8 @@ from typing import Any
|
||||
from duck_core.tools.base import Tool, ToolResult
|
||||
from duck_core.tools.file_read import FileReadTool
|
||||
from duck_core.tools.file_write import FileWriteTool
|
||||
from duck_core.tools.list_dir import ListDirTool
|
||||
from duck_core.tools.search_files import SearchFilesTool
|
||||
from duck_core.tools.shell_exec_safe import ShellExecSafeTool
|
||||
|
||||
|
||||
@@ -16,11 +18,13 @@ class ToolGateway:
|
||||
[
|
||||
FileReadTool(workspace),
|
||||
FileWriteTool(workspace),
|
||||
ListDirTool(workspace),
|
||||
SearchFilesTool(workspace),
|
||||
ShellExecSafeTool(workspace),
|
||||
]
|
||||
)
|
||||
|
||||
async def run_action(self, action: dict[str, Any]) -> ToolResult:
|
||||
async def run_action(self, action: dict[str, Any], approved: bool = False) -> ToolResult:
|
||||
tool_name = str(action.get("tool", ""))
|
||||
tool = self.tools.get(tool_name)
|
||||
if tool is None:
|
||||
@@ -28,4 +32,6 @@ class ToolGateway:
|
||||
args = action.get("args") or {}
|
||||
if not isinstance(args, dict):
|
||||
return ToolResult(ok=False, error="Tool args must be an object")
|
||||
if approved:
|
||||
args = {**args, "_approved": True}
|
||||
return await tool.run(args)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
from typing import Any
|
||||
|
||||
from duck_core.tools.base import ToolResult
|
||||
from duck_core.tools.paths import WorkspacePathError, resolve_workspace_path
|
||||
|
||||
|
||||
class ListDirTool:
|
||||
name = "list_dir"
|
||||
risk_level = "low"
|
||||
|
||||
def __init__(self, workspace: str, max_entries: int = 200):
|
||||
self.workspace = workspace
|
||||
self.max_entries = max_entries
|
||||
|
||||
async def run(self, args: dict[str, Any]) -> ToolResult:
|
||||
raw_path = str(args.get("path") or ".")
|
||||
try:
|
||||
path = resolve_workspace_path(self.workspace, raw_path)
|
||||
except WorkspacePathError as exc:
|
||||
return ToolResult(ok=False, error=str(exc))
|
||||
if not path.exists():
|
||||
return ToolResult(ok=False, error=f"Directory not found: {raw_path}")
|
||||
if not path.is_dir():
|
||||
return ToolResult(ok=False, error=f"Not a directory: {raw_path}")
|
||||
|
||||
entries = []
|
||||
for child in sorted(path.iterdir(), key=lambda item: (not item.is_dir(), item.name.lower())):
|
||||
suffix = "/" if child.is_dir() else ""
|
||||
entries.append(f"{child.name}{suffix}")
|
||||
if len(entries) >= self.max_entries:
|
||||
break
|
||||
|
||||
truncated = len(entries) >= self.max_entries
|
||||
return ToolResult(
|
||||
ok=True,
|
||||
output="\n".join(entries),
|
||||
metadata={
|
||||
"path": str(path),
|
||||
"entries": len(entries),
|
||||
"truncated": truncated,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,66 @@
|
||||
from fnmatch import fnmatch
|
||||
from typing import Any
|
||||
|
||||
from duck_core.tools.base import ToolResult
|
||||
from duck_core.tools.paths import WorkspacePathError, resolve_workspace_path
|
||||
|
||||
|
||||
class SearchFilesTool:
|
||||
name = "search_files"
|
||||
risk_level = "low"
|
||||
|
||||
def __init__(self, workspace: str, max_matches: int = 100, max_file_bytes: int = 1_000_000):
|
||||
self.workspace = workspace
|
||||
self.max_matches = max_matches
|
||||
self.max_file_bytes = max_file_bytes
|
||||
|
||||
async def run(self, args: dict[str, Any]) -> ToolResult:
|
||||
query = str(args.get("query") or "")
|
||||
raw_path = str(args.get("path") or ".")
|
||||
pattern = str(args.get("glob") or "*")
|
||||
case_sensitive = bool(args.get("case_sensitive", True))
|
||||
max_matches = min(int(args.get("max_matches") or self.max_matches), self.max_matches)
|
||||
if not query:
|
||||
return ToolResult(ok=False, error="Search query is required")
|
||||
try:
|
||||
root = resolve_workspace_path(self.workspace, ".")
|
||||
path = resolve_workspace_path(self.workspace, raw_path)
|
||||
except WorkspacePathError as exc:
|
||||
return ToolResult(ok=False, error=str(exc))
|
||||
if not path.exists():
|
||||
return ToolResult(ok=False, error=f"Search path not found: {raw_path}")
|
||||
|
||||
needle = query if case_sensitive else query.lower()
|
||||
matches: list[str] = []
|
||||
files_scanned = 0
|
||||
candidates = [path] if path.is_file() else path.rglob("*")
|
||||
for candidate in candidates:
|
||||
if len(matches) >= max_matches:
|
||||
break
|
||||
if not candidate.is_file() or ".git" in candidate.parts:
|
||||
continue
|
||||
relative = candidate.relative_to(root).as_posix()
|
||||
if not fnmatch(candidate.name, pattern) and not fnmatch(relative, pattern):
|
||||
continue
|
||||
if candidate.stat().st_size > self.max_file_bytes:
|
||||
continue
|
||||
files_scanned += 1
|
||||
text = candidate.read_text(errors="replace")
|
||||
for line_number, line in enumerate(text.splitlines(), start=1):
|
||||
haystack = line if case_sensitive else line.lower()
|
||||
if needle in haystack:
|
||||
matches.append(f"{relative}:{line_number}:{line}")
|
||||
if len(matches) >= max_matches:
|
||||
break
|
||||
|
||||
return ToolResult(
|
||||
ok=True,
|
||||
output="\n".join(matches),
|
||||
metadata={
|
||||
"path": str(path),
|
||||
"query": query,
|
||||
"matches": len(matches),
|
||||
"files_scanned": files_scanned,
|
||||
"truncated": len(matches) >= max_matches,
|
||||
},
|
||||
)
|
||||
@@ -57,7 +57,8 @@ class ShellExecSafeTool:
|
||||
|
||||
async def run(self, args: dict[str, Any]) -> ToolResult:
|
||||
command = str(args.get("command", "")).strip()
|
||||
allowed, reason = self._is_allowed(command)
|
||||
approved = bool(args.get("_approved"))
|
||||
allowed, reason = self._is_allowed(command, approved=approved)
|
||||
if not allowed:
|
||||
return ToolResult(ok=False, error=reason, metadata={"requires_approval": True})
|
||||
try:
|
||||
@@ -79,13 +80,15 @@ class ShellExecSafeTool:
|
||||
metadata={"returncode": completed.returncode, "command": command},
|
||||
)
|
||||
|
||||
def _is_allowed(self, command: str) -> tuple[bool, str | None]:
|
||||
def _is_allowed(self, command: str, approved: bool = False) -> tuple[bool, str | None]:
|
||||
if not command:
|
||||
return False, "Empty command"
|
||||
lowered = command.lower()
|
||||
for blocked in BLOCKLIST:
|
||||
if lowered.startswith(blocked.lower()) or blocked.lower() in lowered:
|
||||
return False, f"Command is blocked: {blocked}"
|
||||
if approved:
|
||||
return True, None
|
||||
parts = shlex.split(command)
|
||||
prefix1 = parts[0] if parts else ""
|
||||
prefix2 = " ".join(parts[:2])
|
||||
|
||||
Reference in New Issue
Block a user