1019 lines
38 KiB
Python
1019 lines
38 KiB
Python
"""
|
||
pipeline-service v2: Agent Executor — 智能执行引擎
|
||
|
||
对照 Hermes Agent 的 run_conversation() + tool dispatch:
|
||
- 多轮 tool-calling loop,max_turns 可配置
|
||
- Memory + Skills 注入每轮 system prompt
|
||
- 上下文压缩(近 token 上限时自动压缩)
|
||
- 并行 tool calls(同一轮内独立工具同时执行)
|
||
- 消息数组污染防护(tool_call JSON 不进入 LLM 上下文)
|
||
- 会话持久化(用户+项目隔离)
|
||
- ask_user 澄清机制
|
||
- delegate_subtask 子任务分发
|
||
|
||
使用方法:
|
||
executor = AgentExecutor(config, project_id=..., user_id=...)
|
||
async for chunk in executor.run("用户输入"):
|
||
yield chunk # NDJSON 流式输出
|
||
"""
|
||
|
||
import asyncio
|
||
import json
|
||
import logging
|
||
import time
|
||
from typing import AsyncGenerator, Dict, List, Optional
|
||
|
||
logger = logging.getLogger("pipeline.agent_executor")
|
||
|
||
# ── 默认工具定义(在 pipeline-core 未加载时使用)──
|
||
|
||
_BUILTIN_ASK_USER_SCHEMA = {
|
||
"name": "ask_user",
|
||
"description": "向用户提问澄清意图。不确定用户想要什么时使用。",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"question": {"type": "string", "description": "要问的问题"},
|
||
},
|
||
"required": ["question"],
|
||
},
|
||
}
|
||
|
||
|
||
class AgentExecutor:
|
||
"""Agent 执行引擎。
|
||
|
||
负责:
|
||
1. 组装 system prompt(配置 + 记忆 + 技能 + 工具)
|
||
2. 运行 tool-loop(LLM 决策 → 工具执行 → 回传结果)
|
||
3. 上下文压缩
|
||
4. 会话持久化
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
config, # AgentConfig (from pipeline_core)
|
||
project_id: str = "",
|
||
user_id: str = "",
|
||
workspace_dir: str = "",
|
||
model_name: str = None,
|
||
):
|
||
self.config = config
|
||
self.project_id = project_id
|
||
self.user_id = user_id
|
||
self.org_id = "" # loaded from project context
|
||
self.workspace_dir = workspace_dir or "/tmp/pipeline_ws"
|
||
self.model_name = model_name or config.model_name
|
||
|
||
# 运行状态
|
||
self._msgs: List[dict] = [] # LLM 消息数组
|
||
self._turn_count: int = 0
|
||
self._tool_call_count: int = 0
|
||
self._session_id: str = ""
|
||
self._started_at: float = 0.0
|
||
|
||
# 懒加载
|
||
self._tool_registry = None
|
||
self._skill_loader = None
|
||
self._memory_store = None
|
||
self._compressor = None
|
||
self._session_mgr = None
|
||
|
||
# ═══════════════════════════════════════════════════════
|
||
# 主入口
|
||
# ═══════════════════════════════════════════════════════
|
||
|
||
async def run(self, user_input: str, history: List[dict] = None) -> AsyncGenerator[str, None]:
|
||
"""执行 agent 主循环。
|
||
|
||
Args:
|
||
user_input: 用户输入
|
||
history: 历史消息列表 [{"role":"user"|"assistant","content":"..."}]
|
||
|
||
Yields:
|
||
NDJSON 行:{"type":"progress"|"tool_call"|"reply"|"error", ...}
|
||
"""
|
||
self._started_at = time.time()
|
||
self._turn_count = 0
|
||
self._tool_call_count = 0
|
||
|
||
# Step 1: 初始化
|
||
await self._init_components()
|
||
|
||
# Step 2: 加载历史
|
||
hist_msgs = await self._load_history(history)
|
||
|
||
# Step 3: 组装 system prompt
|
||
system_prompt = await self._build_system_prompt(user_input)
|
||
|
||
# Step 4: 构建消息数组
|
||
self._msgs = [{"role": "system", "content": system_prompt}]
|
||
if hist_msgs:
|
||
self._msgs.extend(hist_msgs)
|
||
self._msgs.append({"role": "user", "content": user_input})
|
||
|
||
# Step 5: Tool Loop
|
||
yield json.dumps({"type": "progress", "message": "思考中..."}, ensure_ascii=False) + "\n"
|
||
|
||
final_reply = ""
|
||
|
||
max_turns = self.config.max_turns
|
||
for turn in range(max_turns):
|
||
self._turn_count = turn + 1
|
||
|
||
# 5a. 上下文压缩
|
||
if self.config.compression.enabled:
|
||
await self._maybe_compress()
|
||
|
||
# 5b. LLM 调用
|
||
raw = await self._call_llm()
|
||
|
||
# 5c. 解析 LLM 输出
|
||
actions = self._parse_actions(raw)
|
||
|
||
# 5d. 分发处理
|
||
for act in actions:
|
||
action_type = act.get("action", "")
|
||
|
||
if action_type == "reply":
|
||
final_reply = act.get("message", "")
|
||
yield json.dumps({
|
||
"type": "reply", "message": final_reply,
|
||
}, ensure_ascii=False) + "\n"
|
||
await self._save_turn(user_input, final_reply)
|
||
return
|
||
|
||
elif action_type == "tool_call":
|
||
tool_name = act.get("tool", "")
|
||
tool_params = act.get("params", {})
|
||
|
||
# 需要确认的工具
|
||
if self._needs_confirmation(tool_name):
|
||
yield json.dumps({
|
||
"type": "confirm",
|
||
"tool": tool_name,
|
||
"params": tool_params,
|
||
}, ensure_ascii=False) + "\n"
|
||
await self._save_turn(user_input, f"[需确认] {tool_name}")
|
||
return
|
||
|
||
# 执行工具
|
||
yield json.dumps({
|
||
"type": "tool_call", "tool": tool_name,
|
||
"params": tool_params,
|
||
}, ensure_ascii=False) + "\n"
|
||
|
||
result = await self._execute_tool(tool_name, tool_params)
|
||
self._tool_call_count += 1
|
||
|
||
yield json.dumps({
|
||
"type": "tool_result", "tool": tool_name,
|
||
"result": result[:500],
|
||
}, ensure_ascii=False) + "\n"
|
||
|
||
# 反馈给 LLM(关键:不存原始 tool_call JSON)
|
||
self._msgs.append({
|
||
"role": "assistant",
|
||
"content": f"已调用 {tool_name}",
|
||
})
|
||
self._msgs.append({
|
||
"role": "user",
|
||
"content": f"工具 {tool_name} 结果:\n{result}",
|
||
})
|
||
|
||
elif action_type == "error":
|
||
final_reply = act.get("message", "处理出错")
|
||
yield json.dumps({
|
||
"type": "error", "message": final_reply,
|
||
}, ensure_ascii=False) + "\n"
|
||
return
|
||
|
||
# 超过 max_turns
|
||
final_reply = final_reply or "任务过于复杂,请简化描述后重试。"
|
||
yield json.dumps({
|
||
"type": "reply", "message": final_reply,
|
||
}, ensure_ascii=False) + "\n"
|
||
await self._save_turn(user_input, final_reply)
|
||
|
||
# ═══════════════════════════════════════════════════════
|
||
# 初始化
|
||
# ═══════════════════════════════════════════════════════
|
||
|
||
async def _init_components(self):
|
||
"""懒加载各组件"""
|
||
# Tool Registry
|
||
try:
|
||
from pipeline_core.tool_registry import get_tool_registry
|
||
self._tool_registry = get_tool_registry()
|
||
except ImportError:
|
||
self._tool_registry = None
|
||
|
||
# Skill Loader — 三级隔离
|
||
try:
|
||
from pipeline_core.skill_loader import get_skill_loader
|
||
base_dir = self.config.skills.base_dir
|
||
self._skill_loader = get_skill_loader(base_dir)
|
||
except ImportError:
|
||
self._skill_loader = None
|
||
|
||
# Memory Store
|
||
try:
|
||
from pipeline_core.memory_store import get_memory_store
|
||
self._memory_store = get_memory_store()
|
||
except ImportError:
|
||
self._memory_store = None
|
||
|
||
# 加载 org_id(从项目上下文)
|
||
if self.project_id:
|
||
try:
|
||
from sqlor.dbpools import DBPools
|
||
db = DBPools()
|
||
async with db.sqlorContext("pipeline") as sor:
|
||
recs = await sor.sqlExe(
|
||
"SELECT org_id FROM sd_projects WHERE id=${pid}$",
|
||
{"pid": self.project_id})
|
||
if recs:
|
||
self.org_id = getattr(recs[0], 'org_id', '') or ''
|
||
except Exception:
|
||
pass
|
||
|
||
async def _build_system_prompt(self, user_input: str) -> str:
|
||
"""组装完整 system prompt = 基础 prompt + 记忆 + 技能 + 工具列表"""
|
||
prompt = self.config.system_prompt
|
||
|
||
# 注入当前项目
|
||
prompt = prompt.replace("{current_project}", self.project_id or "未选择")
|
||
|
||
# 注入记忆
|
||
if self.config.memory.enabled and self._memory_store:
|
||
mem_block = await self._memory_store.build_prompt_block(max_entries=15)
|
||
if mem_block:
|
||
prompt += f"\n\n## 持久记忆\n{mem_block}"
|
||
|
||
# 注入技能(三级隔离:user > org > global)
|
||
if self.config.skills.enabled and self._skill_loader:
|
||
skill_block = self._skill_loader.build_prompt_block(
|
||
user_input=user_input,
|
||
org_id=self.org_id if self.config.skills.enable_org else "",
|
||
user_id=self.user_id if self.config.skills.enable_user else "",
|
||
max_skills=self.config.skills.max_skills_per_turn,
|
||
)
|
||
if skill_block:
|
||
prompt += f"\n\n{skill_block}"
|
||
|
||
# 注入工具列表
|
||
prompt += "\n\n## 可用工具\n"
|
||
if self._tool_registry:
|
||
prompt += self._tool_registry.to_text_description()
|
||
else:
|
||
for t in self.config.tools:
|
||
if t.enabled:
|
||
params = ", ".join(f"{k}: {v}" for k, v in (t.parameters or {}).items())
|
||
prompt += f"- {t.name}({params}): {t.description}\n"
|
||
|
||
# 注入项目上下文
|
||
try:
|
||
ctx = await self._load_project_context()
|
||
prompt = prompt.replace("{project_context}", ctx)
|
||
except Exception:
|
||
prompt = prompt.replace("{project_context}", "")
|
||
|
||
return prompt
|
||
|
||
# ═══════════════════════════════════════════════════════
|
||
# LLM 调用
|
||
# ═══════════════════════════════════════════════════════
|
||
|
||
async def _call_llm(self) -> str:
|
||
"""调用 LLM,返回原始文本"""
|
||
from pipeline_service.llm_bridge import llm_call_msgs
|
||
|
||
# 使用 OpenAI native function calling(如果模型支持)
|
||
use_native = False
|
||
tools_schema = None
|
||
if self._tool_registry and use_native:
|
||
tools_schema = self._tool_registry.to_openai_schema()
|
||
|
||
# 简单消息调用(兼容所有模型)
|
||
return await llm_call_msgs(
|
||
self._msgs,
|
||
model=self.model_name,
|
||
temperature=self.config.temperature,
|
||
)
|
||
|
||
# ═══════════════════════════════════════════════════════
|
||
# 解析 LLM 输出
|
||
# ═══════════════════════════════════════════════════════
|
||
|
||
def _parse_actions(self, raw: str) -> List[dict]:
|
||
"""解析 LLM 输出,支持单个 JSON 或连续多个 JSON。
|
||
|
||
对照 HA:解析器成熟处理多 JSON + 容错。
|
||
"""
|
||
raw = (raw or "").strip()
|
||
|
||
# 去除 markdown 代码块包裹
|
||
if raw.startswith("```"):
|
||
parts = raw.split("\n", 1)
|
||
if len(parts) > 1:
|
||
raw = parts[1]
|
||
if raw.endswith("```"):
|
||
raw = raw[:-3]
|
||
raw = raw.strip()
|
||
|
||
results = []
|
||
|
||
# 尝试多 JSON 解析
|
||
# LLM 可能输出: {"action":"tool_call",...}\n{"action":"tool_call",...}
|
||
lines = raw.split("\n")
|
||
buffer = ""
|
||
depth = 0
|
||
|
||
for line in lines:
|
||
line_stripped = line.strip()
|
||
if not line_stripped:
|
||
if buffer:
|
||
# 尝试解析 buffer
|
||
result = self._try_parse_json(buffer)
|
||
if result:
|
||
results.append(result)
|
||
buffer = ""
|
||
continue
|
||
|
||
buffer += line_stripped
|
||
|
||
# 简单大括号计数
|
||
depth += line_stripped.count("{") - line_stripped.count("}")
|
||
|
||
if depth == 0 and buffer:
|
||
result = self._try_parse_json(buffer)
|
||
if result:
|
||
results.append(result)
|
||
buffer = ""
|
||
|
||
# 处理残留
|
||
if buffer:
|
||
result = self._try_parse_json(buffer)
|
||
if result:
|
||
results.append(result)
|
||
|
||
if not results:
|
||
# 完全无法解析 JSON → 当作纯文本回复
|
||
results.append({"action": "reply", "message": raw})
|
||
|
||
return results
|
||
|
||
def _try_parse_json(self, text: str) -> Optional[dict]:
|
||
"""容错 JSON 解析"""
|
||
text = text.strip()
|
||
try:
|
||
d = json.loads(text)
|
||
if isinstance(d, dict):
|
||
return d
|
||
except (json.JSONDecodeError, ValueError):
|
||
# 尝试提取第一个 JSON 对象
|
||
for start_char in ["{", "["]:
|
||
if start_char in text:
|
||
idx = text.index(start_char)
|
||
end_char = "}" if start_char == "{" else "]"
|
||
end_idx = text.rfind(end_char)
|
||
if end_idx > idx:
|
||
try:
|
||
d = json.loads(text[idx:end_idx + 1])
|
||
if isinstance(d, dict):
|
||
return d
|
||
except (json.JSONDecodeError, ValueError):
|
||
pass
|
||
return None
|
||
|
||
# ═══════════════════════════════════════════════════════
|
||
# 工具执行
|
||
# ═══════════════════════════════════════════════════════
|
||
|
||
async def _execute_tool(self, tool_name: str, params: dict) -> str:
|
||
"""执行工具,返回结果字符串。
|
||
|
||
优先级:
|
||
1. ToolRegistry 注册的 handler
|
||
2. SDLC 内建工具(兼容 cockpit_chat.dspy 的 TOOLS)
|
||
3. ask_user(始终可用)
|
||
"""
|
||
# 1. 注册的 handler
|
||
if self._tool_registry:
|
||
handler = self._tool_registry.get_handler(tool_name)
|
||
if handler:
|
||
try:
|
||
result = await handler(params, {
|
||
"project_id": self.project_id,
|
||
"user_id": self.user_id,
|
||
"workspace_dir": self.workspace_dir,
|
||
})
|
||
return str(result)
|
||
except Exception as e:
|
||
logger.error(f"Tool {tool_name} handler error: {e}")
|
||
return f"ERROR: {str(e)[:300]}"
|
||
|
||
# 2. SDLC 内建工具
|
||
result = await self._execute_sdlc_tool(tool_name, params)
|
||
if result is not None:
|
||
return result
|
||
|
||
# 3. ask_user
|
||
if tool_name == "ask_user":
|
||
return f"QUESTION: {params.get('question', '')}"
|
||
|
||
return f"未知工具: {tool_name}"
|
||
|
||
async def _execute_sdlc_tool(self, tool_name: str, params: dict) -> Optional[str]:
|
||
"""SDLC 内建工具(兼容 cockpit_chat.dspy)"""
|
||
# 导入现有 handler
|
||
try:
|
||
from sqlor.dbpools import DBPools
|
||
|
||
db = DBPools()
|
||
async with db.sqlorContext("pipeline") as sor:
|
||
return await self._dispatch_sdlc_tool(sor, tool_name, params)
|
||
except Exception as e:
|
||
logger.error(f"SDLC tool {tool_name} error: {e}")
|
||
return f"ERROR: {str(e)[:300]}"
|
||
|
||
async def _dispatch_sdlc_tool(self, sor, tool_name: str, params: dict) -> str:
|
||
"""SDL 工具分发(兼容 cockpit_chat.dspy 的 16 个工具)"""
|
||
p = params or {}
|
||
pid = self.project_id
|
||
|
||
# 所有工具映射(含别名)
|
||
handlers = {
|
||
"switch_project": self._t_switch_project,
|
||
"create_project": self._t_create_project,
|
||
"create_task": self._t_create_task,
|
||
"list_tasks": self._t_list_tasks,
|
||
"task_detail": self._t_task_detail,
|
||
"get_task": self._t_task_detail, # 别名
|
||
"start_agents": self._t_start_agents,
|
||
"diagnose_project": self._t_diagnose_project,
|
||
"list_deliverables": self._t_list_deliverables,
|
||
"view_deliverable": self._t_view_deliverable,
|
||
"get_deliverable": self._t_view_deliverable, # 别名
|
||
"list_questions": self._t_list_questions,
|
||
"answer_question": self._t_answer_question,
|
||
"add_repo": self._t_add_repo,
|
||
"list_repos": self._t_list_repos,
|
||
"run_command": self._t_run_command,
|
||
"check_progress": self._t_check_progress,
|
||
"add_bug": self._t_add_bug,
|
||
}
|
||
|
||
handler = handlers.get(tool_name)
|
||
if handler:
|
||
return await handler(sor, p, pid)
|
||
return None
|
||
|
||
# ── 工具实现 ──
|
||
|
||
async def _t_switch_project(self, sor, p, pid):
|
||
name = p.get("project_name", "").strip()
|
||
if not name:
|
||
return "需要项目名称"
|
||
|
||
# 精确匹配
|
||
recs = await sor.sqlExe(
|
||
"SELECT id, name FROM sd_projects WHERE name=${n}$", {"n": name})
|
||
if recs:
|
||
r = recs[0]
|
||
self.project_id = getattr(r, "id", "")
|
||
return f"OK: 已切换到 {getattr(r, 'name', name)}"
|
||
|
||
# LLM 分类匹配
|
||
try:
|
||
from pipeline_service.llm_bridge import llm_call
|
||
|
||
all_recs = await sor.sqlExe(
|
||
"SELECT name FROM sd_projects ORDER BY created_at DESC LIMIT 20", {})
|
||
pnames = [getattr(r, "name", "") for r in (all_recs or [])]
|
||
classify_prompt = f"用户输入: {name}\n项目列表: {', '.join(pnames)}\n\n判断用户想要哪个项目。只回复项目名或\"不存在\"。"
|
||
matched = await llm_call(classify_prompt, temperature=0.0)
|
||
matched = matched.strip().strip('"').strip("'")
|
||
|
||
recs2 = await sor.sqlExe(
|
||
"SELECT id, name FROM sd_projects WHERE name=${n}$", {"n": matched})
|
||
if recs2:
|
||
r = recs2[0]
|
||
self.project_id = getattr(r, "id", "")
|
||
return f"OK: 已切换到 {getattr(r, 'name', matched)}"
|
||
except Exception:
|
||
pass
|
||
|
||
return f"未找到项目: {name}"
|
||
|
||
async def _t_create_project(self, sor, p, pid):
|
||
from appPublic.uniqueID import getID
|
||
|
||
name = p.get("name", "").strip()
|
||
desc = p.get("description", "")
|
||
if not name:
|
||
return "需要项目名称"
|
||
|
||
pid_val = getID()
|
||
await sor.C("sd_projects", {
|
||
"id": pid_val, "name": name, "description": desc,
|
||
"status": "active",
|
||
})
|
||
await sor.C("sd_iterations", {
|
||
"id": getID(), "project_id": pid_val,
|
||
"iteration_name": f"{name}-初始迭代",
|
||
"iteration_type": "default", "status": "active", "priority": 1,
|
||
})
|
||
self.project_id = pid_val
|
||
return f"OK: 已创建项目 {name}"
|
||
|
||
async def _t_create_task(self, sor, p, pid):
|
||
if not pid:
|
||
return "请先切换到项目"
|
||
|
||
from appPublic.uniqueID import getID
|
||
|
||
title = p.get("title", "").strip()
|
||
role = p.get("role", "develop")
|
||
desc = p.get("description", "")
|
||
if not title:
|
||
return "需要任务标题"
|
||
|
||
tid = getID()
|
||
await sor.C("pipeline_tasks", {
|
||
"id": tid, "tenant_id": pid, "pipeline_id": "role_task",
|
||
"owner_id": self.user_id or "user", "title": title,
|
||
"state": "submitted", "role": role,
|
||
"params": json.dumps({"description": desc}, ensure_ascii=False),
|
||
})
|
||
return f"OK: 已创建任务 {title}(角色: {role})"
|
||
|
||
async def _t_list_tasks(self, sor, p, pid):
|
||
if not pid:
|
||
return "请先切换到项目"
|
||
|
||
role = p.get("role", "")
|
||
state = p.get("state", "")
|
||
|
||
sql = "SELECT id, title, role, state, created_at FROM pipeline_tasks WHERE tenant_id=${pid}$"
|
||
params_dict = {"pid": pid}
|
||
if role:
|
||
sql += " AND role=${role}$"
|
||
params_dict["role"] = role
|
||
if state:
|
||
sql += " AND state=${state}$"
|
||
params_dict["state"] = state
|
||
sql += " ORDER BY created_at DESC LIMIT 30"
|
||
|
||
recs = await sor.sqlExe(sql, params_dict)
|
||
if not recs:
|
||
return "暂无任务"
|
||
|
||
lines = []
|
||
for r in recs:
|
||
lines.append(
|
||
f"- [{getattr(r, 'state', '?')}] {getattr(r, 'title', '')} "
|
||
f"(角色: {getattr(r, 'role', '')}) "
|
||
f"id={getattr(r, 'id', '')[:8]}"
|
||
)
|
||
return "\n".join(lines)
|
||
|
||
async def _t_task_detail(self, sor, p, pid):
|
||
tid = p.get("task_id", "")
|
||
if not tid:
|
||
return "需要任务ID"
|
||
|
||
recs = await sor.sqlExe(
|
||
"SELECT * FROM pipeline_tasks WHERE id=${tid}$", {"tid": tid})
|
||
if not recs:
|
||
return f"任务不存在: {tid}"
|
||
|
||
r = recs[0]
|
||
return json.dumps({
|
||
"id": getattr(r, "id", ""),
|
||
"title": getattr(r, "title", ""),
|
||
"state": getattr(r, "state", ""),
|
||
"role": getattr(r, "role", ""),
|
||
"params": getattr(r, "params", "{}"),
|
||
}, ensure_ascii=False, indent=2)
|
||
|
||
async def _t_start_agents(self, sor, p, pid):
|
||
if not pid:
|
||
return "请先切换到项目"
|
||
|
||
# 找 submitted 任务
|
||
recs = await sor.sqlExe(
|
||
"SELECT id, title, role FROM pipeline_tasks "
|
||
"WHERE tenant_id=${pid}$ AND state='submitted' ORDER BY created_at ASC LIMIT 10",
|
||
{"pid": pid})
|
||
if not recs:
|
||
return "没有待执行的任务"
|
||
|
||
results = []
|
||
for r in recs:
|
||
tid = getattr(r, "id", "")
|
||
role = getattr(r, "role", "develop")
|
||
try:
|
||
from pipeline_service.agent_loop import role_agent_run
|
||
|
||
result = await role_agent_run(pid, role)
|
||
results.append(f"- {getattr(r, 'title', '')}: {result.get('status', '?')}")
|
||
except Exception as e:
|
||
results.append(f"- {getattr(r, 'title', '')}: error={str(e)[:100]}")
|
||
|
||
return "Agent 执行结果:\n" + "\n".join(results)
|
||
|
||
async def _t_diagnose_project(self, sor, p, pid):
|
||
if not pid:
|
||
return "请先切换到项目"
|
||
|
||
# 汇总各种状态
|
||
submitted = await sor.sqlExe(
|
||
"SELECT COUNT(*) as c FROM pipeline_tasks WHERE tenant_id=${pid}$ AND state='submitted'",
|
||
{"pid": pid})
|
||
running = await sor.sqlExe(
|
||
"SELECT COUNT(*) as c FROM pipeline_tasks WHERE tenant_id=${pid}$ AND state='running'",
|
||
{"pid": pid})
|
||
failed = await sor.sqlExe(
|
||
"SELECT COUNT(*) as c FROM pipeline_tasks WHERE tenant_id=${pid}$ AND state='failed'",
|
||
{"pid": pid})
|
||
review = await sor.sqlExe(
|
||
"SELECT COUNT(*) as c FROM pipeline_tasks WHERE tenant_id=${pid}$ AND state='review'",
|
||
{"pid": pid})
|
||
questions = await sor.sqlExe(
|
||
"SELECT COUNT(*) as c FROM pipeline_agent_questions WHERE tenant_id=${pid}$ AND answered IS NULL",
|
||
{"pid": pid})
|
||
|
||
sc = getattr(submitted[0], "c", 0) if submitted else 0
|
||
rc = getattr(running[0], "c", 0) if running else 0
|
||
fc = getattr(failed[0], "c", 0) if failed else 0
|
||
vc = getattr(review[0], "c", 0) if review else 0
|
||
qc = getattr(questions[0], "c", 0) if questions else 0
|
||
|
||
return (
|
||
f"项目诊断:\n"
|
||
f"- 待执行: {sc}\n"
|
||
f"- 运行中: {rc}\n"
|
||
f"- 待审核: {vc}\n"
|
||
f"- 失败: {fc}\n"
|
||
f"- 待回答问题: {qc}\n"
|
||
)
|
||
|
||
async def _t_list_deliverables(self, sor, p, pid):
|
||
if not pid:
|
||
return "请先切换到项目"
|
||
|
||
role = p.get("role", "")
|
||
sql = "SELECT id, title, deliverable_type, review_status FROM pipeline_deliverables WHERE project_id=${pid}$"
|
||
params_dict = {"pid": pid}
|
||
sql += " ORDER BY created_at DESC LIMIT 20"
|
||
|
||
recs = await sor.sqlExe(sql, params_dict)
|
||
if not recs:
|
||
return "暂无交付件"
|
||
|
||
lines = []
|
||
for r in recs:
|
||
lines.append(
|
||
f"- [{getattr(r, 'review_status', '?')}] {getattr(r, 'title', '')} "
|
||
f"({getattr(r, 'deliverable_type', '')})"
|
||
)
|
||
return "\n".join(lines)
|
||
|
||
async def _t_view_deliverable(self, sor, p, pid):
|
||
did = p.get("deliverable_id", "")
|
||
if not did:
|
||
return "需要交付件ID"
|
||
|
||
recs = await sor.sqlExe(
|
||
"SELECT * FROM pipeline_deliverables WHERE id=${did}$", {"did": did})
|
||
if not recs:
|
||
return f"交付件不存在: {did}"
|
||
|
||
r = recs[0]
|
||
return getattr(r, "content", "")[:4000] or "(空)"
|
||
|
||
async def _t_list_questions(self, sor, p, pid):
|
||
if not pid:
|
||
return "请先切换到项目"
|
||
|
||
recs = await sor.sqlExe(
|
||
"SELECT id, question, answer_source FROM pipeline_agent_questions "
|
||
"WHERE tenant_id=${pid}$ AND answered IS NULL ORDER BY created_at DESC LIMIT 10",
|
||
{"pid": pid})
|
||
if not recs:
|
||
return "没有待回答问题"
|
||
|
||
lines = []
|
||
for r in recs:
|
||
lines.append(f"- Q{getattr(r, 'id', '')[:8]}: {getattr(r, 'question', '')[:200]}")
|
||
return "\n".join(lines)
|
||
|
||
async def _t_answer_question(self, sor, p, pid):
|
||
qid = p.get("question_id", "")
|
||
answer = p.get("answer", "")
|
||
if not qid or not answer:
|
||
return "需要问题ID和回答内容"
|
||
|
||
await sor.sqlExe(
|
||
"UPDATE pipeline_agent_questions SET answered=1, answer=${a}$ WHERE id=${qid}$",
|
||
{"a": answer, "qid": qid})
|
||
return "OK: 已回答"
|
||
|
||
async def _t_add_repo(self, sor, p, pid):
|
||
if not pid:
|
||
return "请先切换到项目"
|
||
|
||
from appPublic.uniqueID import getID
|
||
|
||
url = p.get("repo_url", "").strip()
|
||
name = p.get("repo_name", "").strip()
|
||
branch = p.get("default_branch", "main")
|
||
if not url:
|
||
return "需要仓库URL"
|
||
|
||
await sor.C("sd_project_repos", {
|
||
"id": getID(), "project_id": pid,
|
||
"repo_url": url, "repo_name": name or url.split("/")[-1].replace(".git", ""),
|
||
"default_branch": branch,
|
||
})
|
||
return f"OK: 已添加仓库 {name or url}"
|
||
|
||
async def _t_list_repos(self, sor, p, pid):
|
||
if not pid:
|
||
return "请先切换到项目"
|
||
|
||
recs = await sor.sqlExe(
|
||
"SELECT repo_name, repo_url, default_branch FROM sd_project_repos WHERE project_id=${pid}$",
|
||
{"pid": pid})
|
||
if not recs:
|
||
return "暂无关联仓库"
|
||
|
||
lines = []
|
||
for r in recs:
|
||
lines.append(f"- {getattr(r, 'repo_name', '')}: {getattr(r, 'repo_url', '')}")
|
||
return "\n".join(lines)
|
||
|
||
async def _t_run_command(self, sor, p, pid):
|
||
cmd = p.get("command", "")
|
||
if not cmd:
|
||
return "需要命令"
|
||
|
||
try:
|
||
from pipeline_service.agent_loop import _run_shell
|
||
|
||
r = await _run_shell(cmd, self.workspace_dir, timeout=60)
|
||
return f"rc={r['rc']}\n{r['stdout'][:2000]}"
|
||
except Exception as e:
|
||
return f"ERROR: {str(e)[:300]}"
|
||
|
||
async def _t_check_progress(self, sor, p, pid):
|
||
if not pid:
|
||
return "请先切换到项目"
|
||
|
||
recs = await sor.sqlExe(
|
||
"SELECT role, state, COUNT(*) as c FROM pipeline_tasks "
|
||
"WHERE tenant_id=${pid}$ GROUP BY role, state ORDER BY role, state",
|
||
{"pid": pid})
|
||
if not recs:
|
||
return "暂无进度数据"
|
||
|
||
lines = []
|
||
for r in recs:
|
||
lines.append(
|
||
f"- {getattr(r, 'role', '?')}: {getattr(r, 'state', '?')} x{getattr(r, 'c', 0)}"
|
||
)
|
||
return "\n".join(lines)
|
||
|
||
async def _t_add_bug(self, sor, p, pid):
|
||
if not pid:
|
||
return "请先切换到项目"
|
||
|
||
from appPublic.uniqueID import getID
|
||
|
||
title = p.get("title", "").strip()
|
||
desc = p.get("description", "")
|
||
severity = p.get("severity", "medium")
|
||
if not title:
|
||
return "需要Bug标题"
|
||
|
||
await sor.C("sd_bugs", {
|
||
"id": getID(), "title": title,
|
||
"description": desc, "severity": severity,
|
||
"status": "open",
|
||
})
|
||
return f"OK: 已提交Bug: {title}"
|
||
|
||
# ═══════════════════════════════════════════════════════
|
||
# 上下文压缩
|
||
# ═══════════════════════════════════════════════════════
|
||
|
||
async def _maybe_compress(self):
|
||
"""检查是否需要压缩上下文"""
|
||
est_tokens = self._estimate_tokens()
|
||
limit = self.config.context_limit
|
||
threshold = int(limit * self.config.compression.threshold)
|
||
|
||
if est_tokens < threshold:
|
||
return
|
||
|
||
logger.info(f"Compressing: {est_tokens}/{limit} tokens (threshold={threshold})")
|
||
|
||
# 保留 system + 最近 N 轮
|
||
keep = self.config.compression.keep_recent * 2 # user + assistant 各一条
|
||
system_msg = self._msgs[0] if self._msgs else None
|
||
recent = self._msgs[-keep:] if keep < len(self._msgs) else self._msgs[1:]
|
||
|
||
# 压缩中间消息
|
||
middle = self._msgs[1:-keep] if keep < len(self._msgs) else []
|
||
if middle:
|
||
summary = await self._summarize(middle)
|
||
compressed = [
|
||
system_msg,
|
||
{"role": "user", "content": f"[对话摘要]\n{summary}"},
|
||
] if system_msg else []
|
||
compressed.extend(recent)
|
||
self._msgs = compressed
|
||
|
||
def _estimate_tokens(self) -> int:
|
||
"""简单 token 估算(中文 ~1.5 字符/token,英文 ~4 字符/token)"""
|
||
total = 0
|
||
for m in self._msgs:
|
||
content = m.get("content", "")
|
||
# 简单估算:平均每 2 字符 1 token
|
||
total += len(content) // 2 + 1
|
||
return total
|
||
|
||
async def _summarize(self, messages: list) -> str:
|
||
"""压缩消息为摘要"""
|
||
if len(messages) <= 2:
|
||
return "\n".join(m.get("content", "")[:200] for m in messages)
|
||
|
||
text = "\n".join(
|
||
f"[{m['role']}]: {m.get('content', '')[:500]}"
|
||
for m in messages
|
||
)
|
||
|
||
try:
|
||
from pipeline_service.llm_bridge import llm_call
|
||
|
||
summary = await llm_call(
|
||
f"请用3-5句话总结以下对话的关键信息:\n\n{text[:4000]}",
|
||
temperature=0.1,
|
||
)
|
||
return summary[:500]
|
||
except Exception:
|
||
return text[:500]
|
||
|
||
# ═══════════════════════════════════════════════════════
|
||
# 会话管理
|
||
# ═══════════════════════════════════════════════════════
|
||
|
||
async def _load_history(self, external_history: List[dict] = None) -> List[dict]:
|
||
"""加载历史消息。隔离策略:project 级别。"""
|
||
if external_history:
|
||
return external_history
|
||
|
||
if self.config.session_isolation == "none":
|
||
return []
|
||
|
||
try:
|
||
from sqlor.dbpools import DBPools
|
||
|
||
db = DBPools()
|
||
async with db.sqlorContext("pipeline") as sor:
|
||
# 项目隔离
|
||
pid = self.project_id
|
||
if self.config.session_isolation == "project" and pid:
|
||
recs = await sor.sqlExe(
|
||
"SELECT role, content FROM pipeline_conversations "
|
||
"WHERE iteration_id=${pid}$ AND created_by=${uid}$ "
|
||
"ORDER BY created_at ASC LIMIT ${lim}$",
|
||
{"pid": pid, "uid": self.user_id or "", "lim": self.config.history_limit},
|
||
)
|
||
else:
|
||
recs = await sor.sqlExe(
|
||
"SELECT role, content FROM pipeline_conversations "
|
||
"WHERE created_by=${uid}$ "
|
||
"ORDER BY created_at ASC LIMIT ${lim}$",
|
||
{"uid": self.user_id or "", "lim": self.config.history_limit},
|
||
)
|
||
|
||
if not recs:
|
||
return []
|
||
|
||
# 过滤 tool_call JSON 污染(关键!)
|
||
msgs = []
|
||
for r in recs:
|
||
role = getattr(r, "role", "")
|
||
content = getattr(r, "content", "") or ""
|
||
# 跳过 tool_call JSON
|
||
if content.startswith('{"action":"tool_call"'):
|
||
continue
|
||
if content.startswith("已调用 "):
|
||
continue
|
||
if role in ("user", "assistant"):
|
||
msgs.append({"role": role, "content": content})
|
||
return msgs
|
||
except Exception as e:
|
||
logger.warning(f"History load failed: {e}")
|
||
return []
|
||
|
||
async def _save_turn(self, user_input: str, reply: str):
|
||
"""持久化本轮对话"""
|
||
if self.config.session_isolation == "none":
|
||
return
|
||
|
||
try:
|
||
from sqlor.dbpools import DBPools
|
||
from appPublic.uniqueID import getID
|
||
|
||
db = DBPools()
|
||
async with db.sqlorContext("pipeline") as sor:
|
||
# 用户消息
|
||
await sor.C("pipeline_conversations", {
|
||
"id": getID(),
|
||
"role": "user",
|
||
"content": user_input,
|
||
"created_by": self.user_id or "",
|
||
"iteration_id": self.project_id or "",
|
||
})
|
||
# 助手回复(不含 tool_call JSON!)
|
||
reply_clean = reply
|
||
if reply.startswith('{"action":"tool_call"'):
|
||
reply_clean = "[工具调用]"
|
||
await sor.C("pipeline_conversations", {
|
||
"id": getID(),
|
||
"role": "assistant",
|
||
"content": reply_clean[:4000],
|
||
"created_by": self.user_id or "",
|
||
"iteration_id": self.project_id or "",
|
||
})
|
||
except Exception as e:
|
||
logger.warning(f"Session save failed: {e}")
|
||
|
||
async def _load_project_context(self) -> str:
|
||
"""加载项目上下文(项目名、任务数等)"""
|
||
if not self.project_id:
|
||
return ""
|
||
|
||
try:
|
||
from sqlor.dbpools import DBPools
|
||
|
||
db = DBPools()
|
||
async with db.sqlorContext("pipeline") as sor:
|
||
recs = await sor.sqlExe(
|
||
"SELECT name, description FROM sd_projects WHERE id=${pid}$",
|
||
{"pid": self.project_id})
|
||
if recs:
|
||
name = getattr(recs[0], "name", "")
|
||
return f"项目: {name}"
|
||
except Exception:
|
||
pass
|
||
return ""
|
||
|
||
def _needs_confirmation(self, tool_name: str) -> bool:
|
||
"""检查工具是否需要用户确认"""
|
||
if self._tool_registry:
|
||
tool = self._tool_registry.get(tool_name)
|
||
if tool and tool.requires_confirmation:
|
||
return True
|
||
return False
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════
|
||
# 便捷函数
|
||
# ═══════════════════════════════════════════════════════════
|
||
|
||
async def run_agent(
|
||
user_input: str,
|
||
project_id: str = "",
|
||
user_id: str = "",
|
||
config=None,
|
||
model_name: str = None,
|
||
workspace_dir: str = "",
|
||
) -> AsyncGenerator[str, None]:
|
||
"""便捷入口:运行 agent 并流式输出。
|
||
|
||
用法:
|
||
async for chunk in run_agent("创建一个新项目"):
|
||
print(chunk)
|
||
"""
|
||
if config is None:
|
||
try:
|
||
from pipeline_core.agent_config import load_agent_config, SDLC_DEFAULT_CONFIG
|
||
|
||
config = await load_agent_config(project_id=project_id)
|
||
except ImportError:
|
||
config = SDLC_DEFAULT_CONFIG
|
||
|
||
executor = AgentExecutor(
|
||
config=config,
|
||
project_id=project_id,
|
||
user_id=user_id,
|
||
workspace_dir=workspace_dir,
|
||
model_name=model_name,
|
||
)
|
||
async for chunk in executor.run(user_input):
|
||
yield chunk
|