Add approval continuation and multi-step tools

This commit is contained in:
2026-05-20 03:34:13 +08:00
parent 2d3a047548
commit a4b7ef034a
13 changed files with 982 additions and 85 deletions
+7 -1
View File
@@ -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)
+42
View File
@@ -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,
},
)
+66
View File
@@ -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,
},
)
+5 -2
View File
@@ -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])