feat(v3): T22-T23 harness 级工具扩展 + 工作区选择后端

- T22 工具内核:edit_file(old_string 唯一命中才替换,防误改)、search_files
  (跨文件内容搜索,跳过 .git/node_modules 与二进制大文件)、run_command
  (allow_shell 默认关;超时+输出截断+Windows CREATE_NO_WINDOW)
- T23 工作区选择(参考 deepseek-harness 打开文件夹体验):/agent/fs 磁盘目录浏览
  (空 path 列 Windows 盘符)、/agent/workspaces 最近列表持久化、
  POST /agent 接受 workspace(须存在目录),运行状态记录所用工作区
- 新增测试 12 项,全量 274 passed
This commit is contained in:
tzt
2026-09-01 10:16:58 +08:00
parent 10bd4cc71d
commit 8358302002
6 changed files with 440 additions and 24 deletions
+198 -1
View File
@@ -24,6 +24,15 @@ MAX_LIST_ENTRIES = 200
MAX_RESULT_CHARS = 8000
MAX_WRITE_CHARS = 200_000
# search_files 上限
SEARCH_MAX_MATCHES = 30
SEARCH_MAX_FILES = 400
SEARCH_MAX_FILE_BYTES = 512 * 1024
SEARCH_SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv", "dist", "build"}
# run_command 上限
SHELL_OUTPUT_CHARS = 4000
# 默认循环上限
DEFAULT_MAX_ROUNDS = 8
@@ -71,11 +80,89 @@ TOOLS_SPEC: List[Dict[str, Any]] = [
},
},
},
{
"type": "function",
"function": {
"name": "edit_file",
"description": "对工作区内已有文件做精确替换编辑:old_string 必须在文件中恰好出现一次,"
"被替换为 new_string。适合小改动;大改用 write_file 整体重写。",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "工作区内的相对路径"},
"old_string": {"type": "string", "description": "要替换的原文(须唯一匹配)"},
"new_string": {"type": "string", "description": "替换后的新文"},
},
"required": ["path", "old_string", "new_string"],
},
},
},
{
"type": "function",
"function": {
"name": "search_files",
"description": "在工作区(或其子目录)内按关键词跨文件搜索文本内容,"
"返回匹配的文件/行号/行内容(自动跳过 .git、node_modules 等目录与二进制大文件)。",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "搜索的关键词(大小写敏感)"},
"path": {"type": "string", "description": "限定的子目录,默认整个工作区"},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "run_command",
"description": "在工作区根目录执行一条 shell 命令并返回退出码与输出(如运行测试、查看版本)。"
"仅当系统开启 allow_shell 时可用。",
"parameters": {
"type": "object",
"properties": {
"command": {"type": "string", "description": "要执行的命令行"},
},
"required": ["command"],
},
},
},
]
TOOL_NAMES = {t["function"]["name"] for t in TOOLS_SPEC}
def browse_directories(path: str = "") -> Dict[str, Any]:
"""目录选择器的本地文件系统浏览(只列目录,不读文件内容)。
path 为空时列出 Windows 盘符(POSIX 列根目录)。返回
{"ok", "path", "parent", "dirs": [名称]};用于智能体"选择工作区"
"""
import os
if not path or not path.strip():
if os.name == "nt":
drives = [f"{c}:\\" for c in "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
if os.path.exists(f"{c}:\\")]
return {"ok": True, "path": "", "parent": "", "dirs": drives}
return {"ok": True, "path": "/", "parent": "",
"dirs": sorted(os.listdir("/"))}
p = Path(path).resolve()
if not p.exists():
return {"ok": False, "error": f"路径不存在: {path}"}
if not p.is_dir():
return {"ok": False, "error": f"不是目录: {path}"}
dirs = []
for child in sorted(p.iterdir(), key=lambda c: c.name.lower()):
try:
if child.is_dir():
dirs.append(child.name)
except OSError:
continue # 无权限/符号链接坏点,跳过
parent = str(p.parent) if p.parent != p else ""
return {"ok": True, "path": str(p), "parent": parent, "dirs": dirs}
class ToolError(Exception):
"""工具执行失败(路径越界/不存在/参数非法)。"""
@@ -87,9 +174,12 @@ class WorkspaceTools:
(根目录自身允许),否则抛 ToolError——防 ../ 越界与绝对路径逃逸。
"""
def __init__(self, root: str | Path):
def __init__(self, root: str | Path,
allow_shell: bool = False, shell_timeout_s: int = 20):
self.root = Path(root).resolve()
self.root.mkdir(parents=True, exist_ok=True)
self.allow_shell = bool(allow_shell)
self.shell_timeout_s = max(1, int(shell_timeout_s))
# ---------- 路径关押 ----------
def resolve(self, rel_path: str) -> Path:
@@ -147,6 +237,103 @@ class WorkspaceTools:
p.write_text(content, encoding="utf-8")
return {"ok": True, "path": rel_path, "bytes_written": len(content.encode("utf-8"))}
def edit_file(self, rel_path: str, old_string: str, new_string: str) -> Dict[str, Any]:
"""精确替换编辑:old_string 必须在文件中恰好出现一次(harness 式安全编辑)。"""
if not old_string:
return {"ok": False, "error": "old_string 不能为空"}
if len(old_string) > MAX_READ_CHARS:
return {"ok": False, "error": "old_string 过长(先 read_file 分段定位)"}
p = self.resolve(rel_path)
if not p.exists() or not p.is_file():
return {"ok": False, "error": f"文件不存在: {rel_path}"}
try:
text = p.read_text(encoding="utf-8")
except UnicodeDecodeError:
return {"ok": False, "error": f"非文本文件: {rel_path}"}
count = text.count(old_string)
if count == 0:
return {"ok": False, "error": "old_string 未在文件中找到(先 read_file 核对原文)"}
if count > 1:
return {"ok": False,
"error": f"old_string 出现 {count} 次(要求唯一);请扩大上下文使其唯一"}
new_text = text.replace(old_string, new_string, 1)
p.write_text(new_text, encoding="utf-8")
return {
"ok": True, "path": rel_path,
"replaced": 1,
"changed_chars": len(new_text) - len(text),
}
def search_files(self, query: str, rel_path: str = "") -> Dict[str, Any]:
"""跨文件文本搜索(跳过依赖/构建目录与二进制大文件,限量返回)。"""
if not query:
return {"ok": False, "error": "query 不能为空"}
base = self.resolve(rel_path or "")
if not base.exists() or not base.is_dir():
return {"ok": False, "error": f"目录不存在: {rel_path}"}
matches: List[Dict[str, Any]] = []
scanned = 0
truncated = False
for p in sorted(base.rglob("*")):
if len(matches) >= SEARCH_MAX_MATCHES:
truncated = True
break
if not p.is_file():
continue
rel_parts = p.relative_to(base).parts
if any(part in SEARCH_SKIP_DIRS for part in rel_parts):
continue
try:
if p.stat().st_size > SEARCH_MAX_FILE_BYTES:
continue
text = p.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue # 二进制/不可读,跳过
scanned += 1
if scanned > SEARCH_MAX_FILES:
truncated = True
break
for lineno, line in enumerate(text.splitlines(), 1):
if query in line:
rel = Path(*p.relative_to(self.root).parts).as_posix()
matches.append({
"file": rel, "line": lineno,
"text": line.strip()[:300],
})
if len(matches) >= SEARCH_MAX_MATCHES:
truncated = True
break
return {"ok": True, "query": query, "matches": matches,
"scanned_files": scanned, "truncated": truncated}
def run_command(self, command: str) -> Dict[str, Any]:
"""在工作区根目录执行 shell 命令(默认关闭,allow_shell 开启后可用)。"""
if not self.allow_shell:
return {"ok": False,
"error": "run_command 未启用(系统设置 allow_shell 为关)。"
"请让用户在智能体页打开「允许执行命令」后重试。"}
command = (command or "").strip()
if not command:
return {"ok": False, "error": "command 不能为空"}
import subprocess
creationflags = 0x08000000 if __import__("os").name == "nt" else 0 # CREATE_NO_WINDOW
try:
proc = subprocess.run(
command, shell=True, cwd=str(self.root), capture_output=True,
timeout=self.shell_timeout_s, creationflags=creationflags,
)
out = (proc.stdout or b"").decode("utf-8", errors="replace")
err = (proc.stderr or b"").decode("utf-8", errors="replace")
combined = (out + ("\n[stderr]\n" + err if err.strip() else "")).strip()
if len(combined) > SHELL_OUTPUT_CHARS:
combined = combined[:SHELL_OUTPUT_CHARS] + "…(输出截断)"
return {"ok": True, "exit_code": proc.returncode,
"output": combined or "(无输出)", "command": command}
except subprocess.TimeoutExpired:
return {"ok": False, "error": f"命令超时(>{self.shell_timeout_s}s),已终止: {command}"}
except OSError as e:
return {"ok": False, "error": f"命令执行失败: {type(e).__name__}: {e}"}
# ---------- 统一执行入口 ----------
def execute(self, name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
"""按名字执行工具;任何异常折叠为 {"ok": False, "error": ...}。"""
@@ -158,6 +345,16 @@ class WorkspaceTools:
if name == "write_file":
return self.write_file(
str(arguments.get("path", "")), str(arguments.get("content", "")))
if name == "edit_file":
return self.edit_file(
str(arguments.get("path", "")),
str(arguments.get("old_string", "")),
str(arguments.get("new_string", "")))
if name == "search_files":
return self.search_files(
str(arguments.get("query", "")), str(arguments.get("path", "")))
if name == "run_command":
return self.run_command(str(arguments.get("command", "")))
return {"ok": False, "error": f"未知工具: {name}"}
except ToolError as e:
return {"ok": False, "error": str(e)}