1317 lines
53 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
pipeline-service v2: Agent Executor — 智能执行引擎
对照 Hermes Agent 的 run_conversation() + tool dispatch
- 多轮 tool-calling loopmax_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-loopLLM 决策 → 工具执行 → 回传结果)
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._auto_push_count: int = 0 # 限制 auto-inject 次数
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 调用native function calling返回 dict
resp = await self._call_llm()
# 5c. 原生 function calling优先处理 tool_calls
native_calls = (resp.get("tool_calls") or []) if isinstance(resp, dict) else []
if native_calls:
# 回填 assistant 消息(含 tool_callsOpenAI 原生格式要求)
self._msgs.append({
"role": "assistant",
"content": resp.get("content") or None,
"tool_calls": native_calls,
})
for tc in native_calls:
fn = tc.get("function", {}) if isinstance(tc, dict) else {}
tool_name = fn.get("name", "")
try:
tool_params = json.loads(fn.get("arguments") or "{}")
except Exception:
tool_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)
if not result.startswith("未知工具") and not result.startswith("ERROR"):
self._tool_call_count += 1
yield json.dumps({
"type": "tool_result", "tool": tool_name, "result": result[:500],
}, ensure_ascii=False) + "\n"
# 原生回填role=tool + tool_call_id
self._msgs.append({
"role": "tool",
"tool_call_id": tc.get("id", "") if isinstance(tc, dict) else "",
"content": str(result),
})
continue
# 5d. 文本解析(回退路径,纯文本模型)
raw = resp.get("content", "") if isinstance(resp, dict) else str(resp)
actions = self._parse_actions(raw)
# 5e. 分发处理
for act in actions:
action_type = act.get("action", "")
if action_type == "reply":
# 任何轮次,只要没成功调过工具就 reply → 强制注入工具调用
if self._tool_call_count == 0:
# 意图识别:新需求描述(长文本+需求特征词)→ create_task 推进
req_keywords = ["实现", "开发", "建设", "系统", "功能", "需求", "模块",
"支持", "切换", "监控", "备份", "同步", "要做", "设计", "平台"]
is_requirement = (len(user_input) > 40 and
any(kw in user_input for kw in req_keywords))
if is_requirement:
hint_tool = "create_task"
elif any(kw in user_input.lower() for kw in ["任务", "task", "list"]):
hint_tool = "list_tasks"
elif any(kw in user_input.lower() for kw in ["问题", "question"]):
hint_tool = "list_questions"
else:
hint_tool = "diagnose_project"
yield json.dumps({
"type": "auto_tool", "message": f"未调工具,自动注入 {hint_tool}",
}, ensure_ascii=False) + "\n"
self._msgs.append({"role": "assistant", "content": f"已调用 {hint_tool}"})
if hint_tool == "create_task":
self._msgs.append({"role": "user", "content":
"用户描述了新需求,请用 create_task 创建任务推进(可先 role=requirement 做需求分析,再拆分到 develop填好 title/role/description 参数,输出 tool_call JSON。"})
else:
self._msgs.append({"role": "user", "content": f"请调用 {hint_tool} 工具,只输出 {{\"action\":\"tool_call\",\"tool\":\"{hint_tool}\",\"params\":{{}}}}"})
continue
# 调了工具但太少1-2次LLM 想停 → 推它继续深入最多3次
if self._tool_call_count <= 2 and self._auto_push_count < 3:
self._auto_push_count += 1
hint = "请继续:查看失败任务的详情(task_detail),查看待回答问题(list_questions)或启动agent(start_agents)"
yield json.dumps({
"type": "auto_tool", "message": f"已调{self._tool_call_count}次工具,继续深入({self._auto_push_count}/3)",
}, ensure_ascii=False) + "\n"
self._msgs.append({"role": "assistant", "content": "继续分析"})
self._msgs.append({"role": "user", "content": hint})
continue
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)
if not result.startswith("未知工具") and not result.startswith("ERROR"):
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()
# 把 config.tools 注册进 registryregistry 是全局单例,可能为空)
if self._tool_registry and self.config.tools:
existing = set(self._tool_registry.get_tool_names())
for t in self.config.tools:
if t.name not in existing:
self._tool_registry.register(t)
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
# 注入当前项目上下文
proj_name = self.project_id
try:
ctx = await self._load_project_context()
if ctx:
proj_name = ctx.replace("项目: ", "")
except Exception:
pass
prompt += f"\n当前项目: {proj_name}\n" if proj_name else ""
# 注入记忆
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}"
# 注入工具列表(先构建,再替换占位符)
tools_text = ""
if self._tool_registry:
tools_text = self._tool_registry.to_text_description()
else:
lines = []
for t in self.config.tools:
if t.enabled:
params = ", ".join(f"{k}: {v}" for k, v in (t.parameters or {}).items())
lines.append(f"- {t.name}({params}): {t.description}")
tools_text = "\n".join(lines)
prompt = prompt.replace("{project_name}", proj_name)
prompt = prompt.replace("{tools_description}", tools_text)
return prompt
# ═══════════════════════════════════════════════════════
# LLM 调用
# ═══════════════════════════════════════════════════════
async def _call_llm(self) -> dict:
"""调用 LLM返回 {"content": str, "tool_calls": [...]}。
优先使用 OpenAI native function calling。若 registry 不可用或调用失败,
回退到纯文本调用content 为原始文本tool_calls 为空)。
"""
from pipeline_service.llm_bridge import llm_call_msgs_native
tools_schema = None
if self._tool_registry:
try:
tools_schema = self._tool_registry.to_openai_schema()
except Exception as e:
logger.error(f"to_openai_schema failed: {e}")
if tools_schema:
try:
return await llm_call_msgs_native(
self._msgs,
tools=tools_schema,
model=self.model_name,
temperature=self.config.temperature,
)
except Exception as e:
logger.error(f"native function calling failed, fallback to text: {e}")
# 回退纯文本调用content 为原始文本)
from pipeline_service.llm_bridge import llm_call_msgs
content = await llm_call_msgs(
self._msgs,
model=self.model_name,
temperature=self.config.temperature,
)
return {"content": content or "", "tool_calls": []}
# ═══════════════════════════════════════════════════════
# 解析 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,
"get_task_list": self._t_list_tasks,
"get_tasks": self._t_list_tasks, # 别名
"task_detail": self._t_task_detail,
"get_task": self._t_task_detail,
"get_task_detail": 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,
"clone_repo": self._t_clone_repo,
"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 _persist_project(self, sor, pid):
"""持久化当前项目到 pipeline_agent_settingsuser_id 唯一键)。
只改 self.project_id 不够——AgentExecutor 每轮新建run 结束即销毁,
下一轮 cockpit_chat_v2 又从 pipeline_agent_settings 读回旧项目。
"""
if not self.user_id or not pid:
return
try:
from appPublic.uniqueID import getID
await sor.sqlExe(
"UPDATE pipeline_agent_settings SET current_project_id=${pid}$ "
"WHERE user_id=${uid}$",
{"pid": pid, "uid": self.user_id})
exists = await sor.sqlExe(
"SELECT 1 FROM pipeline_agent_settings WHERE user_id=${uid}$",
{"uid": self.user_id})
if not exists:
await sor.sqlExe(
"INSERT INTO pipeline_agent_settings (id, user_id, current_project_id) "
"VALUES (${id}$, ${uid}$, ${pid}$)",
{"id": getID(), "uid": self.user_id, "pid": pid})
except Exception as e:
logger.warning(f"persist project failed: {e}")
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", "")
await self._persist_project(sor, self.project_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", "")
await self._persist_project(sor, self.project_id)
return f"OK: 已切换到 {getattr(r, 'name', matched)}"
except Exception:
pass
return f"未找到项目: {name}"
async def _t_create_project(self, sor, p, pid):
import os
from appPublic.uniqueID import getID
name = p.get("name", "").strip()
desc = p.get("description", "")
if not name:
return "需要项目名称"
# 查当前用户 org_id工作空间按 org 隔离)
org_id = "0"
if self.user_id:
_u = await sor.sqlExe(
"SELECT orgid FROM users WHERE id=${u}$ LIMIT 1", {"u": self.user_id})
if _u:
org_id = getattr(_u[0], "orgid", "0") or "0"
# 项目专属工作空间目录workspace_base 从 appbase params 表读,可动态配置)
# 不设置 workspace_dir 会导致 _resolve_workspace 回退到 ~/pipeline_ws/default
# 所有项目共用同一目录,设计文档/代码互相覆盖。
from .workspace import get_workspace_base
workspace_base = await get_workspace_base(sor)
workspace_dir = os.path.join(workspace_base, str(org_id), name)
os.makedirs(workspace_dir, exist_ok=True)
pid_val = getID()
await sor.C("sd_projects", {
"id": pid_val, "name": name, "description": desc,
"status": "active", "org_id": org_id, "workspace_dir": workspace_dir,
})
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
await self._persist_project(sor, 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 "暂无任务"
# v2.1 format: markdown table
icons = {
"submitted": "", "running": "🔄", "review": "👀",
"approved": "", "completed": "✔️", "failed": "",
"waiting": "⏸️",
}
lines = ["| 状态 | 任务 | 角色 | ID |",
"|------|------|------|----|"]
for r in recs:
st = getattr(r, 'state', '?')
icon = icons.get(st, "")
title = (getattr(r, 'title', '') or '')[:60]
role = getattr(r, 'role', '')
tid = getattr(r, 'id', '')
lines.append(f"| {icon} {st} | {title} | {role} | {tid} |")
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 and len(tid) >= 6:
# 前缀匹配兜底(兼容 8 位截断 ID
recs = await sor.sqlExe(
"SELECT * FROM pipeline_tasks WHERE id LIKE ${prefix}$ LIMIT 1",
{"prefix": 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 表可能没有 answered 列,用 status='pending' 兜底
qc = 0
try:
questions = await sor.sqlExe(
"SELECT COUNT(*) as c FROM pipeline_agent_questions WHERE tenant_id=${pid}$ AND status='pending'",
{"pid": pid})
qc = getattr(questions[0], "c", 0) if questions else 0
except Exception:
pass
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
lines = [
"项目诊断:",
f"- 待执行: {sc}",
f"- 运行中: {rc}",
f"- 待审核: {vc}",
f"- 失败: {fc}",
f"- 待回答问题: {qc}",
]
# 列出失败/待执行任务完整 ID方便 agent 直接 task_detail
if fc:
failed_rows = await sor.sqlExe(
"SELECT id, title, role FROM pipeline_tasks WHERE tenant_id=${pid}$ AND state='failed' ORDER BY created_at DESC LIMIT 10",
{"pid": pid})
lines.append("失败任务:")
for fr in (failed_rows or []):
lines.append(f" - [{getattr(fr, 'role', '?')}] {getattr(fr, 'title', '')} (id={getattr(fr, 'id', '')})")
if sc:
submitted_rows = 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})
lines.append("待执行任务:")
for sr in (submitted_rows or []):
lines.append(f" - [{getattr(sr, 'role', '?')}] {getattr(sr, 'title', '')} (id={getattr(sr, 'id', '')})")
# 运行中任务明细claimed_by + 心跳陈旧度,帮助 agent 直接识别僵尸任务
# (进程崩溃/协程挂起后心跳不再更新,超时即疑似僵尸,会被 poller 自动回收重跑)
if rc:
running_rows = await sor.sqlExe(
"SELECT id, title, role, claimed_by, "
"TIMESTAMPDIFF(MINUTE, updated_at, NOW()) AS mins "
"FROM pipeline_tasks WHERE tenant_id=${pid}$ AND state='running' "
"ORDER BY updated_at ASC LIMIT 10",
{"pid": pid})
lines.append("运行中任务:")
for rr in (running_rows or []):
rrole = getattr(rr, 'role', '?')
rtitle = getattr(rr, 'title', '')
rid = getattr(rr, 'id', '')
rcb = getattr(rr, 'claimed_by', '') or ''
try:
mins = int(getattr(rr, 'mins', 0) or 0)
except (TypeError, ValueError):
mins = 0
if mins >= 10:
flag = f"⚠️心跳超时{mins}分钟(疑似僵尸,将被回收重跑)"
else:
flag = f"已运行{mins}分钟"
lines.append(f" - [{rrole}] {rtitle} (id={rid}) | {flag} | claimed_by={rcb[:8]}")
return "\n".join(lines)
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', '')}) id={getattr(r, 'id', '')}"
)
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 and len(did) >= 6:
recs = await sor.sqlExe(
"SELECT * FROM pipeline_deliverables WHERE id LIKE ${prefix}$ LIMIT 1",
{"prefix": 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 "请先切换到项目"
try:
recs = await sor.sqlExe(
"SELECT id, question, answer_source FROM pipeline_agent_questions "
"WHERE tenant_id=${pid}$ AND status='pending' ORDER BY created_at DESC LIMIT 10",
{"pid": pid})
except Exception:
# 兼容无 status 列的情况
try:
recs = await sor.sqlExe(
"SELECT id, question, answer_source FROM pipeline_agent_questions "
"WHERE tenant_id=${pid}$ ORDER BY created_at DESC LIMIT 10",
{"pid": pid})
except Exception:
return "暂无问题数据"
if not recs:
return "没有待回答问题"
lines = []
for r in recs:
lines.append(f"- [{getattr(r, 'id', '')}] {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和回答内容"
# 先精确匹配,再前缀匹配兜底(兼容截断 ID
recs = await sor.sqlExe(
"SELECT id, task_id FROM pipeline_agent_questions WHERE id=${qid}$", {"qid": qid})
if not recs and len(qid) >= 6:
recs = await sor.sqlExe(
"SELECT id, task_id FROM pipeline_agent_questions WHERE id LIKE ${prefix}$ LIMIT 1",
{"prefix": qid + "%"})
if not recs:
return f"问题不存在: {qid}"
full_qid = getattr(recs[0], "id", qid)
task_id = getattr(recs[0], "task_id", "") or ""
await sor.sqlExe(
"UPDATE pipeline_agent_questions SET answer=${a}$, answer_source='main_agent', "
"answered_by='main_agent', status='answered' WHERE id=${qid}$",
{"a": answer, "qid": full_qid})
# 恢复任务waiting → submitted并清 claimed_by。
# 否则任务永久卡在 waitingpoll 器要求 state='submitted' AND claimed_by IS NULL 才会重新认领)。
resumed = False
if task_id:
await sor.sqlExe(
"UPDATE pipeline_tasks SET state='submitted', claimed_by=NULL "
"WHERE id=${tid}$ AND state='waiting'",
{"tid": task_id})
resumed = True
suffix = ",任务已恢复执行" if resumed else ""
return "OK: 已回答" + suffix
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_clone_repo(self, sor, p, pid):
if not pid:
return "请先切换到项目"
import os
from pipeline_service.agent_loop import (
_setup_repos, _get_workspace_dir, _git_clone)
workspace_dir = await _get_workspace_dir(sor, pid)
url = (p.get("repo_url", "") or "").strip()
if url:
name = url.rstrip("/").split("/")[-1].replace(".git", "")
target = os.path.join(workspace_dir, "repos", name)
r = await _git_clone(url, target, p.get("branch", "main"))
return f"rc={r['rc']} {r['message']}"
results = await _setup_repos(sor, workspace_dir, pid)
if not results:
return "无关联仓库可克隆"
return "\n".join(
f"- {x['repo']}: rc={x.get('rc', '?')} {x.get('message', '')}"
for x in results)
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标题"
# 找项目的迭代iteration_id 必填)
iteration_id = p.get("iteration_id", "")
if not iteration_id:
its = await sor.sqlExe(
"SELECT id FROM sd_iterations WHERE project_id=${pid}$ ORDER BY created_at ASC LIMIT 1",
{"pid": pid})
if its:
iteration_id = getattr(its[0], "id", "")
if not iteration_id:
return "ERROR: 项目无迭代无法提交Bug"
await sor.C("sd_bugs", {
"id": getID(), "title": title,
"description": desc, "severity": severity,
"status": "open", "iteration_id": iteration_id,
})
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") or ""
# 简单估算:平均每 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") or "")[:200] for m in messages)
text = "\n".join(
f"[{m['role']}]: {(m.get('content') or '')[: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