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:
+48
-5
@@ -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] + "…(截断)"
|
||||
|
||||
Reference in New Issue
Block a user