feat(v3): T29 收尾 + T31 dsh 功能对齐

T29 流式收尾:
- _stream_partial 每次流式调用前复位(防上次成功置位导致本次失败误抛不回退)

T31 dsh(deepseek-harness)功能对齐:
- OpenAICompatChat 重试退避:传输错误/408/429/5xx 指数退避(max_retries=2),4xx 不重试
- web_fetch 工具:公网 http/https 抓取,SSRF 防护(DNS 后拒绝私网/环回/链路本地/NAT64,
  512KB/15s/12k 上限,二进制嗅探拒绝),agent.allow_net 开关(默认开)
- 原子写入:write_file/edit_file 临时文件 + os.replace(Windows EPERM 退避)
- 慢工具线程卸载:run_command/web_fetch/search_files 独立线程 + asyncio.sleep 轮询
- search_files:os.walk 修剪依赖目录(替代 rglob 全量物化),不跟随符号链接
- run_command:危险命令黑名单独立拦截 + 显式 COMSPEC/sh 解释器执行
- 重复调用提醒:同工具同参数第 3 次起回喂系统提示 + repeat_warning 事件
- 会话重命名:PATCH /agent/sessions/{sid} + 前端 ✎
- 前端:SettingsView 适配密钥打码(留空保留),SPA 重新构建
- 新增 tests/test_agent_features.py(9 项);全量 318 测试通过
This commit is contained in:
tzt
2026-09-02 00:02:24 +08:00
parent ce3ba44de3
commit 747d85c3ba
18 changed files with 356 additions and 26 deletions
+48 -5
View File
@@ -53,6 +53,45 @@ REPEAT_CALL_WARN_AT = 3
# 默认循环上限
DEFAULT_MAX_ROUNDS = 8
# 可能长时间运行的工具(命令执行 / 网络抓取 / 大范围搜索)放独立线程执行,
# 不阻塞事件循环;完成等待用 asyncio.sleep 轮询(补丁运行时的 TestClient
# 每请求独立事件循环,不推进 run_in_executor 桥接;轮询在生产/测试两端都可靠)。
SLOW_TOOLS = {"run_command", "web_fetch", "search_files"}
def _spawn_tool_thread(fn, *args):
"""起守护线程执行 fn(*args),返回 (结果盒子, 线程);轮询线程存活后取盒内值。"""
import threading
box: Dict[str, Any] = {}
def _runner():
try:
box["result"] = fn(*args)
except BaseException as exc: # 线程内异常回传给调用方
box["error"] = exc
t = threading.Thread(target=_runner, daemon=True, name="agenttool")
t.start()
return box, t
def _dispatch_tool(tools, name, arguments):
"""统一工具分发(内部辅助)。"""
return tools.execute(name, arguments)
async def run_tool_async(tools: "WorkspaceTools", name: str,
arguments: Dict[str, Any]) -> Dict[str, Any]:
"""执行一个工具:慢工具放线程并轮询等完成,快工具直接内联执行。"""
if name not in SLOW_TOOLS:
return _dispatch_tool(tools, name, arguments)
box, t = _spawn_tool_thread(_dispatch_tool, tools, name, arguments)
while t.is_alive():
await asyncio.sleep(0.02)
if "error" in box:
raise box["error"]
return box["result"]
# OpenAI tools 声明(chat/completions 请求的 tools 参数)
TOOLS_SPEC: List[Dict[str, Any]] = [
{
@@ -440,13 +479,17 @@ class WorkspaceTools:
if blocked:
return {"ok": False, "error": f"命令被安全策略拒绝({blocked})。请换一种不具破坏性的做法。"}
if os.name == "nt":
argv = [os.environ.get("COMSPEC", "cmd.exe"), "/c", command]
# Windows:显式走 cmd /c(与 shell=True 内部同构——整条命令包一层引号,
# 避免参数列表的 CRT 转义与 cmd 引号语义冲突);COMSPEC 取系统 shell 路径
comspec = os.environ.get("COMSPEC", "cmd.exe")
run_args: Any = f'"{comspec}" /c "{command}"'
creationflags = 0x08000000 # CREATE_NO_WINDOW
else:
argv = ["/bin/sh", "-c", command]
creationflags = 0x08000000 if os.name == "nt" else 0 # CREATE_NO_WINDOW
run_args = ["/bin/sh", "-c", command]
creationflags = 0
try:
proc = subprocess.run(
argv, cwd=str(self.root), capture_output=True,
run_args, cwd=str(self.root), capture_output=True,
timeout=self.shell_timeout_s, creationflags=creationflags,
)
out = (proc.stdout or b"").decode("utf-8", errors="replace")
@@ -756,7 +799,7 @@ class ToolLoop:
messages.append({"role": "tool", "tool_call_id": c["id"],
"content": preview})
continue
result = await asyncio.to_thread(self.tools.execute, c["name"], c["arguments"])
result = await run_tool_async(self.tools, c["name"], c["arguments"])
preview = json.dumps(result, ensure_ascii=False)
if len(preview) > self.result_preview_chars:
preview = preview[:self.result_preview_chars] + "…(截断)"