feat: AgentExecutor实现通用工具集——文件读写/搜索/会话搜索/todo/子代理委派+workspace_dir解析

This commit is contained in:
ymq 2026-08-15 09:04:49 +08:00
parent 1860179a39
commit eaa23d1eff

View File

@ -20,6 +20,7 @@ pipeline-service v2: Agent Executor — 智能执行引擎
import asyncio import asyncio
import json import json
import logging import logging
import os
import time import time
from typing import AsyncGenerator, Dict, List, Optional from typing import AsyncGenerator, Dict, List, Optional
@ -73,6 +74,7 @@ class AgentExecutor:
self._auto_push_count: int = 0 # 限制 auto-inject 次数 self._auto_push_count: int = 0 # 限制 auto-inject 次数
self._session_id: str = "" self._session_id: str = ""
self._started_at: float = 0.0 self._started_at: float = 0.0
self._todos: List[dict] = [] # 会话内任务清单 [{done, content}]
# 懒加载 # 懒加载
self._tool_registry = None self._tool_registry = None
@ -290,17 +292,20 @@ class AgentExecutor:
except ImportError: except ImportError:
self._memory_store = None self._memory_store = None
# 加载 org_id(从项目上下文) # 加载 org_id + workspace_dir(从项目上下文)
if self.project_id: if self.project_id:
try: try:
from sqlor.dbpools import DBPools from sqlor.dbpools import DBPools
db = DBPools() db = DBPools()
async with db.sqlorContext("pipeline") as sor: async with db.sqlorContext("pipeline") as sor:
recs = await sor.sqlExe( recs = await sor.sqlExe(
"SELECT org_id FROM sd_projects WHERE id=${pid}$", "SELECT org_id, workspace_dir FROM sd_projects WHERE id=${pid}$",
{"pid": self.project_id}) {"pid": self.project_id})
if recs: if recs:
self.org_id = getattr(recs[0], 'org_id', '') or '' self.org_id = getattr(recs[0], 'org_id', '') or ''
ws = getattr(recs[0], 'workspace_dir', '') or ''
if ws:
self.workspace_dir = ws
except Exception: except Exception:
pass pass
@ -555,6 +560,14 @@ class AgentExecutor:
"run_command": self._t_run_command, "run_command": self._t_run_command,
"check_progress": self._t_check_progress, "check_progress": self._t_check_progress,
"add_bug": self._t_add_bug, "add_bug": self._t_add_bug,
# ── 通用工具集(Hermes CLI 能力子集)──
"read_file": self._t_read_file,
"write_file": self._t_write_file,
"list_files": self._t_list_files,
"search_files": self._t_search_files,
"session_search": self._t_session_search,
"todo": self._t_todo,
"delegate_subtask": self._t_delegate_subtask,
} }
handler = handlers.get(tool_name) handler = handlers.get(tool_name)
@ -1077,6 +1090,167 @@ class AgentExecutor:
}) })
return f"OK: 已提交Bug: {title}" return f"OK: 已提交Bug: {title}"
# ═══════════════════════════════════════════════════════
# 通用工具集(Hermes CLI 能力子集)
# ═══════════════════════════════════════════════════════
def _resolve_ws_path(self, path: str) -> str:
"""解析相对路径为工作空间内绝对路径(越界返回 '')。"""
import os
ws = self.workspace_dir or "/tmp/pipeline_ws"
full = os.path.abspath(os.path.join(ws, path or "."))
# 限制在 workspace 内
if full != ws and not full.startswith(ws.rstrip("/") + "/"):
return ""
return full
async def _t_read_file(self, sor, p, pid):
path = p.get("path", "")
if not path:
return "FAIL: 需要文件路径"
full = self._resolve_ws_path(path)
if not full:
return f"FAIL: 路径越界 {path}"
try:
if not os.path.isfile(full):
return f"FAIL: 文件不存在 {path}"
with open(full, encoding="utf-8") as f:
return f.read()[:30000]
except Exception as e:
return f"ERROR: {str(e)[:300]}"
async def _t_write_file(self, sor, p, pid):
path = p.get("path", "")
content = p.get("content", "")
if not path:
return "FAIL: 需要文件路径"
full = self._resolve_ws_path(path)
if not full:
return f"FAIL: 路径越界 {path}"
try:
os.makedirs(os.path.dirname(full), exist_ok=True)
with open(full, "w", encoding="utf-8") as f:
f.write(content or "")
return f"OK: 已写入 {path} ({len(content)} 字符)"
except Exception as e:
return f"ERROR: {str(e)[:300]}"
async def _t_list_files(self, sor, p, pid):
path = p.get("path", "") or "."
full = self._resolve_ws_path(path)
if not full:
return f"FAIL: 路径越界 {path}"
try:
if not os.path.isdir(full):
return f"FAIL: 目录不存在 {path}"
items = sorted(os.listdir(full))[:50]
lines = []
for name in items:
fp = os.path.join(full, name)
if name.startswith("."):
continue
t = "DIR" if os.path.isdir(fp) else "FILE"
size = os.path.getsize(fp) if os.path.isfile(fp) else 0
lines.append(f"[{t}] {name} ({size}B)")
return "\n".join(lines) if lines else "(空目录)"
except Exception as e:
return f"ERROR: {str(e)[:300]}"
async def _t_search_files(self, sor, p, pid):
pattern = p.get("pattern", "")
if not pattern:
return "FAIL: 需要搜索关键词"
path = p.get("path", "") or "."
full = self._resolve_ws_path(path)
if not full:
return f"FAIL: 路径越界 {path}"
try:
from pipeline_service.agent_loop import _run_shell
# grep -rn,排除 .git 和 __pycache__,限制输出
r = await _run_shell(
f"grep -rn --include='*.py' --include='*.md' --include='*.json' --include='*.txt' "
f"--exclude-dir=.git --exclude-dir=__pycache__ '{pattern}' . 2>/dev/null | head -50",
full, timeout=30)
out = r.get("stdout", "").strip()
return out[:4000] if out else f"未找到匹配 '{pattern}' 的内容"
except Exception as e:
return f"ERROR: {str(e)[:300]}"
async def _t_session_search(self, sor, p, pid):
query = p.get("query", "").strip()
if not query:
return "FAIL: 需要搜索关键词"
try:
recs = await sor.sqlExe(
"SELECT role, content, created_at FROM pipeline_conversations "
"WHERE created_by=${u}$ AND iteration_id=${pid}$ AND content LIKE ${q}$ "
"ORDER BY created_at DESC LIMIT 10",
{"u": self.user_id, "pid": pid, "q": f"%{query}%"})
if not recs:
return f"未找到包含 '{query}' 的会话记录"
lines = []
for r in recs:
content = (getattr(r, "content", "") or "")[:300]
lines.append(f"[{getattr(r, 'role', '?')}] {content}")
return "\n".join(lines)
except Exception as e:
return f"ERROR: {str(e)[:300]}"
async def _t_todo(self, sor, p, pid):
action = p.get("action", "list")
content = (p.get("content", "") or "").strip()
if action == "add":
if not content:
return "FAIL: add 需要任务内容"
self._todos.append({"done": False, "content": content})
return f"OK: 已添加任务(共 {len(self._todos)} 项)"
if action == "done":
if not content:
return "FAIL: done 需要任务序号或内容"
for t in self._todos:
if content in t["content"] or content == str(self._todos.index(t) + 1):
t["done"] = True
return f"OK: 已完成任务 '{t['content']}'"
return f"未找到任务 '{content}'"
# list(默认)
if not self._todos:
return "任务清单为空"
lines = []
for i, t in enumerate(self._todos):
mark = "✅" if t["done"] else "⏳"
lines.append(f"{i + 1}. {mark} {t['content']}")
return "\n".join(lines)
async def _t_delegate_subtask(self, sor, p, pid):
goal = (p.get("goal", "") or "").strip()
context = (p.get("context", "") or "").strip()
if not goal:
return "FAIL: 需要子任务目标"
try:
sub = AgentExecutor(
config=self.config,
project_id=self.project_id,
user_id=self.user_id,
workspace_dir=self.workspace_dir,
model_name=self.model_name,
)
prompt = goal if not context else f"{goal}\n\n背景:{context}"
result_parts = []
async for chunk in sub.run(prompt):
try:
data = json.loads(chunk)
except Exception:
continue
t = data.get("type", "")
if t == "reply":
result_parts.append(data.get("message", ""))
elif t == "tool_result":
result_parts.append(data.get("result", ""))
out = "\n".join(x for x in result_parts if x).strip()
return out[:3000] or "(子任务无输出)"
except Exception as e:
return f"ERROR: {str(e)[:300]}"
# ═══════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════
# 上下文压缩 # 上下文压缩
# ═══════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════