feat: AgentExecutor实现通用工具集——文件读写/搜索/会话搜索/todo/子代理委派+workspace_dir解析
This commit is contained in:
parent
1860179a39
commit
eaa23d1eff
@ -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]}"
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════
|
||||||
# 上下文压缩
|
# 上下文压缩
|
||||||
# ═══════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user