Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

291 lines
11 KiB
Python

"""Workspace file tools for chat skill/script workflows."""
from __future__ import annotations
import difflib
from pathlib import Path
from typing import Any
from deeptutor.core.tool_protocol import BaseTool, ToolDefinition, ToolParameter, ToolResult
from deeptutor.tools.prompting import load_prompt_hints
def _resolve_workspace_path(path: str, workspace: str, allowed_dir: str) -> Path:
if not workspace or not allowed_dir:
raise PermissionError("workspace file tools are not available for this turn")
raw = Path(str(path or ".")).expanduser()
root = Path(workspace).expanduser().resolve()
allowed = Path(allowed_dir).expanduser().resolve()
candidate = raw if raw.is_absolute() else root / raw
resolved = candidate.resolve()
try:
resolved.relative_to(allowed)
except ValueError as exc:
raise PermissionError(f"path is outside the turn workspace: {path}") from exc
return resolved
class _WorkspaceTool(BaseTool):
def get_prompt_hints(self, language: str = "en"):
return load_prompt_hints(self.name, language=language)
@staticmethod
def _resolve(kwargs: dict[str, Any], path: str) -> Path:
return _resolve_workspace_path(
path,
str(kwargs.get("_workspace_dir") or ""),
str(kwargs.get("_allowed_dir") or ""),
)
class ReadFileTool(_WorkspaceTool):
_DEFAULT_LIMIT = 2000
_MAX_CHARS = 128_000
def get_definition(self) -> ToolDefinition:
return ToolDefinition(
name="read_file",
description="Read a text file from this turn's workspace with line pagination.",
parameters=[
ToolParameter(name="path", type="string", description="Path inside the workspace."),
ToolParameter(
name="offset",
type="integer",
description="1-indexed line number to start from.",
required=False,
),
ToolParameter(
name="limit",
type="integer",
description="Maximum lines to return.",
required=False,
),
],
)
async def execute(self, **kwargs: Any) -> ToolResult:
path = str(kwargs.get("path") or "")
try:
fp = self._resolve(kwargs, path)
if not fp.exists():
return ToolResult(content=f"Error: file not found: {path}", success=False)
if not fp.is_file():
return ToolResult(content=f"Error: not a file: {path}", success=False)
try:
offset = max(1, int(kwargs.get("offset") or 1))
except (TypeError, ValueError):
offset = 1
try:
limit = max(1, int(kwargs.get("limit") or self._DEFAULT_LIMIT))
except (TypeError, ValueError):
limit = self._DEFAULT_LIMIT
lines = fp.read_text(encoding="utf-8", errors="replace").splitlines()
total = len(lines)
if total == 0:
return ToolResult(content=f"(empty file: {path})")
if offset > total:
return ToolResult(
content=f"Error: offset {offset} is beyond end of file ({total} lines)",
success=False,
)
start = offset - 1
end = min(start + limit, total)
numbered = [f"{start + i + 1}| {line}" for i, line in enumerate(lines[start:end])]
text = "\n".join(numbered)
if len(text) > self._MAX_CHARS:
text = text[: self._MAX_CHARS].rstrip() + "\n...[truncated]"
suffix = (
f"\n\n(showing lines {offset}-{end} of {total}; use offset={end + 1} to continue)"
if end < total
else f"\n\n(end of file; {total} lines total)"
)
return ToolResult(content=text + suffix)
except PermissionError as exc:
return ToolResult(content=f"Error: {exc}", success=False)
except Exception as exc:
return ToolResult(content=f"Error reading file: {exc}", success=False)
class WriteFileTool(_WorkspaceTool):
def get_definition(self) -> ToolDefinition:
return ToolDefinition(
name="write_file",
description="Write a UTF-8 text file inside this turn's workspace.",
parameters=[
ToolParameter(name="path", type="string", description="Path inside the workspace."),
ToolParameter(name="content", type="string", description="File contents."),
],
)
async def execute(self, **kwargs: Any) -> ToolResult:
path = str(kwargs.get("path") or "")
content = str(kwargs.get("content") or "")
try:
fp = self._resolve(kwargs, path)
fp.parent.mkdir(parents=True, exist_ok=True)
fp.write_text(content, encoding="utf-8")
return ToolResult(content=f"Successfully wrote {len(content)} bytes to {fp.name}")
except PermissionError as exc:
return ToolResult(content=f"Error: {exc}", success=False)
except Exception as exc:
return ToolResult(content=f"Error writing file: {exc}", success=False)
class EditFileTool(_WorkspaceTool):
def get_definition(self) -> ToolDefinition:
return ToolDefinition(
name="edit_file",
description="Edit a workspace text file by replacing old_text with new_text.",
parameters=[
ToolParameter(name="path", type="string", description="Path inside the workspace."),
ToolParameter(name="old_text", type="string", description="Text to replace."),
ToolParameter(name="new_text", type="string", description="Replacement text."),
ToolParameter(
name="replace_all",
type="boolean",
description="Replace all occurrences instead of one.",
required=False,
),
],
)
async def execute(self, **kwargs: Any) -> ToolResult:
path = str(kwargs.get("path") or "")
old_text = str(kwargs.get("old_text") or "")
new_text = str(kwargs.get("new_text") or "")
replace_all = bool(kwargs.get("replace_all") or False)
try:
fp = self._resolve(kwargs, path)
if not fp.exists():
return ToolResult(content=f"Error: file not found: {path}", success=False)
content = fp.read_text(encoding="utf-8", errors="replace")
if old_text not in content:
return ToolResult(
content=_not_found_message(path, old_text, content),
success=False,
)
count = content.count(old_text)
if count > 1 and not replace_all:
return ToolResult(
content=(
f"Warning: old_text appears {count} times. Provide more context "
"or set replace_all=true."
),
success=False,
)
updated = (
content.replace(old_text, new_text)
if replace_all
else content.replace(old_text, new_text, 1)
)
fp.write_text(updated, encoding="utf-8")
return ToolResult(content=f"Successfully edited {fp.name}")
except PermissionError as exc:
return ToolResult(content=f"Error: {exc}", success=False)
except Exception as exc:
return ToolResult(content=f"Error editing file: {exc}", success=False)
class ListDirTool(_WorkspaceTool):
_DEFAULT_MAX = 200
_IGNORE = {
".git",
"node_modules",
"__pycache__",
".venv",
"venv",
"dist",
"build",
".pytest_cache",
".ruff_cache",
}
def get_definition(self) -> ToolDefinition:
return ToolDefinition(
name="list_dir",
description="List files under this turn's workspace.",
parameters=[
ToolParameter(
name="path",
type="string",
description="Directory path inside the workspace.",
required=False,
default=".",
),
ToolParameter(
name="recursive",
type="boolean",
description="Recursively list nested paths.",
required=False,
),
ToolParameter(
name="max_entries",
type="integer",
description="Maximum entries to return.",
required=False,
),
],
)
async def execute(self, **kwargs: Any) -> ToolResult:
path = str(kwargs.get("path") or ".")
recursive = bool(kwargs.get("recursive") or False)
try:
cap = max(1, int(kwargs.get("max_entries") or self._DEFAULT_MAX))
except (TypeError, ValueError):
cap = self._DEFAULT_MAX
try:
dp = self._resolve(kwargs, path)
if not dp.exists():
return ToolResult(content=f"Error: directory not found: {path}", success=False)
if not dp.is_dir():
return ToolResult(content=f"Error: not a directory: {path}", success=False)
items: list[str] = []
total = 0
iterator = dp.rglob("*") if recursive else dp.iterdir()
for item in sorted(iterator):
if any(part in self._IGNORE for part in item.relative_to(dp).parts):
continue
total += 1
if len(items) < cap:
rel = item.relative_to(dp)
items.append(f"{rel}/" if item.is_dir() else str(rel))
if not items and total == 0:
return ToolResult(content=f"Directory {path} is empty")
text = "\n".join(items)
if total > cap:
text += f"\n\n(truncated, showing first {cap} of {total} entries)"
return ToolResult(content=text)
except PermissionError as exc:
return ToolResult(content=f"Error: {exc}", success=False)
except Exception as exc:
return ToolResult(content=f"Error listing directory: {exc}", success=False)
def _not_found_message(path: str, old_text: str, content: str) -> str:
lines = content.splitlines(keepends=True)
old_lines = old_text.splitlines(keepends=True)
window = max(1, len(old_lines))
best_ratio = 0.0
best_start = 0
for idx in range(max(1, len(lines) - window + 1)):
ratio = difflib.SequenceMatcher(None, old_lines, lines[idx : idx + window]).ratio()
if ratio > best_ratio:
best_ratio = ratio
best_start = idx
if best_ratio > 0.5:
diff = "\n".join(
difflib.unified_diff(
old_lines,
lines[best_start : best_start + window],
fromfile="old_text",
tofile=f"{path} actual line {best_start + 1}",
lineterm="",
)
)
return f"Error: old_text not found. Best match ({best_ratio:.0%} similar):\n{diff}"
return "Error: old_text not found. Verify the file content."
__all__ = ["EditFileTool", "ListDirTool", "ReadFileTool", "WriteFileTool"]