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
+16 -8
View File
@@ -32,10 +32,13 @@ STATE_DONE = "done"
STATE_FAILED = "failed"
AGENT_SYSTEM_PROMPT = (
"你是端云协同 LLM 系统中的智能体(Agent)。你拥有工作区文件工具:"
"list_dir(列目录)、read_file(读文件)、write_file(写文件)。"
"像程序员助手一样工作:先列目录/读文件了解现状,需要时再写文件;"
"任务完成或给出结论后,直接输出给用户的最终答复(中文,不要再调用工具)。"
"你是端云协同 LLM 系统中的智能体(Agent),正在操作用户选择的**真实项目工作目录**。"
"你拥有的工具:list_dir(列目录)、read_file(读文件)、write_file(写文件/新建)、"
"edit_file(精确替换编辑:old_string 须唯一匹配)、search_files(跨文件搜索内容)、"
"run_command(执行 shell 命令,仅当系统开启 allow_shell 时可用,否则不要尝试)。"
"像编程助手一样工作:先列目录/搜索了解项目结构,读文件核对原文后再用 edit_file 小步修改"
"(或 write_file 新建),需要时运行命令验证。任务完成或给出结论后,"
"直接输出给用户的最终答复(中文,不要再调用工具)。"
)
@@ -125,6 +128,7 @@ class AgentRunInfo:
prompt_tokens: int = 0
completion_tokens: int = 0
pool_id: str = ""
workspace: str = "" # 本次运行使用的工作区根目录(绝对路径)
asyncio_task: Optional[asyncio.Task] = field(default=None, repr=False)
def to_dict(self) -> Dict[str, Any]:
@@ -141,6 +145,7 @@ class AgentRunInfo:
"prompt_tokens": self.prompt_tokens,
"completion_tokens": self.completion_tokens,
"pool_id": self.pool_id,
"workspace": self.workspace,
}
@@ -163,12 +168,13 @@ class AgentService:
return self._dir(request_id) / "status.json"
# ---------- 注册与查询 ----------
def register(self, request_id: str, task: str, model: str, pool_id: str) -> Optional[AgentRunInfo]:
def register(self, request_id: str, task: str, model: str, pool_id: str,
workspace: str = "") -> Optional[AgentRunInfo]:
running = [r for r in self._runs.values() if r.state == STATE_RUNNING]
if len(running) >= self.max_running:
return None
info = AgentRunInfo(request_id=request_id, task=task, model=model,
pool_id=pool_id, started_at=time.time())
pool_id=pool_id, workspace=workspace, started_at=time.time())
self._runs[request_id] = info
self._dir(request_id).mkdir(parents=True, exist_ok=True)
self._write_status(info)
@@ -179,9 +185,11 @@ class AgentService:
# ---------- 执行 ----------
async def run(self, info: AgentRunInfo, chat: Any, workspace_dir: str | Path,
max_rounds: int = 8, token_cap: int = 0) -> None:
max_rounds: int = 8, token_cap: int = 0,
allow_shell: bool = False, shell_timeout_s: int = 20) -> None:
"""执行智能体任务(由调用方包成后台协程)。"""
tools = WorkspaceTools(workspace_dir)
tools = WorkspaceTools(workspace_dir, allow_shell=allow_shell,
shell_timeout_s=shell_timeout_s)
loop = ToolLoop(tools, chat, max_rounds=max_rounds, token_cap=token_cap,
on_event=self._make_event_writer(info))
try:
+84 -15
View File
@@ -501,11 +501,13 @@ try:
@app.post("/agent", tags=["agent"])
async def agent_run(req: dict):
"""提交智能体任务:{"task": "...", "pool_id": "可选模型条目"}。
"""提交智能体任务:{"task", "pool_id"?, "workspace"?}。
workspace 为用户选择的工作目录(绝对路径);缺省用设置里的 agent.workspace_dir。
立即返回 request_id;过程事件经 GET /agent/{id}/stream (SSE) 推送。
"""
from gateway.agent import get_agent_service, new_request_id
from router_system.tools import WorkspaceTools
task = str((req or {}).get("task") or "").strip()
if not task:
@@ -514,23 +516,36 @@ try:
raise HTTPException(status_code=400, detail="task 过长(>8000")
pool_id = str((req or {}).get("pool_id") or "")
s = settings_store().to_dict()
agent_cfg = s.get("agent", {})
ws_raw = str((req or {}).get("workspace") or "").strip()
if ws_raw:
ws_path = Path(ws_raw)
if not ws_path.exists():
raise HTTPException(status_code=400, detail=f"工作目录不存在: {ws_raw}")
if not ws_path.is_dir():
raise HTTPException(status_code=400, detail=f"不是目录: {ws_raw}")
workspace_dir = str(ws_path.resolve())
else:
workspace_dir = agent_cfg.get("workspace_dir", "agent_workspace")
chat, model, used_pool_id = _resolve_agent_chat(pool_id)
service = get_agent_service()
request_id = new_request_id()
info = service.register(request_id, task, model, used_pool_id)
info = service.register(request_id, task, model, used_pool_id,
workspace=workspace_dir)
if info is None:
raise HTTPException(status_code=503, detail="智能体同时运行任务已达上限")
s = settings_store().to_dict()
agent_cfg = s.get("agent", {})
async def _run():
try:
await service.run(
info, chat,
workspace_dir=agent_cfg.get("workspace_dir", "agent_workspace"),
workspace_dir=workspace_dir,
max_rounds=int(agent_cfg.get("max_rounds", 8)),
token_cap=int(agent_cfg.get("token_cap", 20000)),
allow_shell=bool(agent_cfg.get("allow_shell", False)),
shell_timeout_s=int(agent_cfg.get("shell_timeout_s", 20)),
)
except Exception as exc:
import traceback
@@ -541,7 +556,50 @@ try:
service._write_status(info)
info.asyncio_task = asyncio.create_task(_run())
return {"request_id": request_id, "status": "running", "model": model}
return {"request_id": request_id, "status": "running", "model": model,
"workspace": workspace_dir}
@app.get("/agent/fs", tags=["agent"])
async def agent_fs_browse(path: str = ""):
"""目录选择器:浏览本地文件系统(只列子目录,不读文件内容)。"""
from router_system.tools import browse_directories
return browse_directories(path)
@app.get("/agent/workspaces", tags=["agent"])
async def agent_workspaces():
"""当前工作区 + 最近打开列表。"""
s = settings_store().to_dict()
agent_cfg = s.get("agent", {})
current = agent_cfg.get("workspace_dir", "agent_workspace")
return {"current": current, "recent": list(agent_cfg.get("recent_workspaces", []))}
@app.post("/agent/workspaces", tags=["agent"])
async def agent_open_workspace(req: dict):
"""打开(或创建)一个工作目录:设为当前并记入最近列表。"""
path = str((req or {}).get("path") or "").strip()
create = bool((req or {}).get("create", False))
if not path:
raise HTTPException(status_code=400, detail="path 不能为空")
p = Path(path)
if not p.exists():
if not create:
raise HTTPException(status_code=400,
detail=f"目录不存在: {path}(可勾选“新建目录”)")
try:
p.mkdir(parents=True, exist_ok=True)
except OSError as e:
raise HTTPException(status_code=400, detail=f"创建失败: {e}")
elif not p.is_dir():
raise HTTPException(status_code=400, detail=f"不是目录: {path}")
resolved = str(p.resolve())
store = settings_store()
store.update({"agent": {"workspace_dir": resolved}})
merged = store.to_dict().get("agent", {})
recent = [w for w in merged.get("recent_workspaces", []) if w != resolved]
recent.insert(0, resolved)
store.update({"agent": {"recent_workspaces": recent[:8]}})
return {"ok": True, "current": resolved,
"recent": store.to_dict().get("agent", {}).get("recent_workspaces", [])}
@app.get("/agent/{request_id}/status", tags=["agent"])
async def agent_status(request_id: str):
@@ -585,22 +643,22 @@ try:
)
@app.get("/agent/workspace", tags=["agent"])
async def agent_workspace(path: str = ""):
"""列出智能体工作区(默认根目录;path 指定子目录)。越界返回 400。"""
async def agent_workspace(path: str = "", root: str = ""):
"""列出智能体工作区内容。root 可指定其他已选工作目录(默认用设置值)。越界/非法返回 400。"""
from router_system.tools import ToolError, WorkspaceTools
s = settings_store().to_dict()
tools = WorkspaceTools(s.get("agent", {}).get("workspace_dir", "agent_workspace"))
base = _agent_workspace_root(root)
tools = WorkspaceTools(base)
try:
return tools.list_dir(path)
except ToolError as e:
raise HTTPException(status_code=400, detail=str(e))
@app.get("/agent/file", tags=["agent"])
async def agent_file(path: str):
"""读取智能体工作区内文件(前端预览,越界即 400)。"""
async def agent_file(path: str, root: str = ""):
"""读取智能体工作区内文件(前端预览,越界即 400)。root 同 /agent/workspace。"""
from router_system.tools import ToolError, WorkspaceTools
s = settings_store().to_dict()
tools = WorkspaceTools(s.get("agent", {}).get("workspace_dir", "agent_workspace"))
base = _agent_workspace_root(root)
tools = WorkspaceTools(base)
try:
result = tools.read_file(path)
except ToolError as e:
@@ -609,6 +667,17 @@ try:
raise HTTPException(status_code=404, detail=result.get("error", "读取失败"))
return result
def _agent_workspace_root(root: str = "") -> str:
"""解析工作区根:显式 root(须为已存在目录)> 设置值。"""
root = (root or "").strip()
if root:
p = Path(root)
if not p.is_dir():
raise HTTPException(status_code=400, detail=f"工作目录不存在: {root}")
return str(p.resolve())
s = settings_store().to_dict()
return s.get("agent", {}).get("workspace_dir", "agent_workspace")
# ---------------- 模型设置(用户可调整) ----------------
@app.get("/config", tags=["settings"])
async def get_config():
+3
View File
@@ -38,8 +38,11 @@ DEFAULTS: Dict[str, Any] = {
},
"agent": {
"workspace_dir": "agent_workspace", # 智能体工作区根目录(越界即拒)
"recent_workspaces": [], # 最近打开的工作区(供快速切换)
"max_rounds": 8, # 工具循环轮数上限
"token_cap": 20000, # 单次智能体任务 token 熔断
"allow_shell": False, # 允许 run_command 执行 shell(默认关)
"shell_timeout_s": 20, # shell 命令超时
},
}