1668 lines
76 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 os
import time
from typing import AsyncGenerator, Dict, List, Optional
logger = logging.getLogger("pipeline.agent_executor")
# 项目管理工具别名表LLM 常编造近义名(停止/终止/删除项目的各种说法),
# 归一到真实工具名。只加「语义等价」的别名,不扩大权限(权限校验在代码层)。
# 注意:不能映射既有真实工具名(如 list_projects 是 sdlc_ability 的产线项目列表
# 工具,语义与 list_my_projects 不同),否则会劫持既有功能。
_PROJECT_TOOL_ALIASES = {
"stop_project": "pause_project",
"terminate_project": "pause_project",
"suspend_project": "pause_project",
"halt_project": "pause_project",
"unpause_project": "resume_project",
"continue_project": "resume_project",
"remove_project": "delete_project",
"destroy_project": "delete_project",
"get_project_info": "project_info",
"show_project": "project_info",
"my_projects": "list_my_projects",
}
from .workspace import WORKSPACE_BASE, GENERAL_SPACE
# ── 默认工具定义(在 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,
role: str = "", # 产线内角色(可选,如 develop/design驾驶舱 agent 为空)
base_url: str = "", # 请求 base_urlscheme://host/),供 slash 命令生成 widget 绝对 URL
generic: bool = False, # True = 纯通用会话(不解析项目、不挂产线能力)
session=None, # GatewaySession会话级状态approve_all/pending_confirm
session_id: str = "", # 会话内唯一标识web 多 tab 独立会话;历史隔离键)
default_pipeline_id: str = "", # 无当前项目时的默认产线(入口指定,如 bidding_general
):
self.config = config
self.project_id = project_id
self.user_id = user_id
self.org_id = "" # loaded from project context
self.pipeline_id = default_pipeline_id or "" # 默认产线(入口指定);有项目时被项目 pipeline_id 覆盖
self.role = role # 产线内角色(技能/工具/prompt 按角色加载)
self.generic = generic # 是否纯通用会话
self._session = session # GatewaySessionapprove_all/pending_confirm 会话级状态)
self.session_id = session_id # 会话内唯一标识(历史消息按此隔离)
self.space = GENERAL_SPACE # 项目空间键generic→'general',产线→真实 pipeline_id
self.workspace_dir = workspace_dir or WORKSPACE_BASE
self.model_name = model_name or config.model_name
self.base_url = base_url
# 运行状态
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._todos: List[dict] = [] # 会话内任务清单 [{done, content}]
# 懒加载
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 0: 初始化(解析 pipeline_id / skill_loader 等slash 命令也需要)
await self._init_components()
# Step 0.5: slash 命令短路(/xxx 直接执行,不进入 LLM 循环)
if user_input and user_input.strip().startswith("/"):
from pipeline_core import resolve_slash_command, parse_slash_args
cmd = resolve_slash_command(user_input.strip(), self.pipeline_id, self.role)
if cmd and cmd.handler:
args = parse_slash_args(user_input.strip())
try:
result = await cmd.handler(args, self._build_slash_ctx())
except Exception as e:
logger.error(f"slash /{cmd.name} error: {e}")
result = f"ERROR: {str(e)[:300]}"
yield json.dumps({"type": "reply", "message": result}, ensure_ascii=False) + "\n"
return
# 未知 slash 命令:提示并列出可用命令
from pipeline_core import get_slash_commands
visible = get_slash_commands(self.pipeline_id, self.role)
help_text = "未知命令。可用: " + ", ".join(f"/{n}" for n in sorted(visible))
yield json.dumps({"type": "reply", "message": help_text}, ensure_ascii=False) + "\n"
return
# 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 4.5: 危险命令确认回复短路pending_confirm + 确认/全部确认/取消)
confirm_decision = self._detect_confirm_decision(user_input)
if confirm_decision and self._session and self._session.pending_confirm:
pending = self._session.pending_confirm
if confirm_decision == "cancel":
self._session.pending_confirm = None
yield json.dumps({"type": "reply", "message": "已取消该操作。"}, ensure_ascii=False) + "\n"
await self._save_turn(user_input, "已取消该操作。")
return
if confirm_decision == "approve_all":
self._session.approve_all = True
self._session.pending_confirm = None
pending_tool = pending.get("tool", "")
pending_params = pending.get("params") or {}
# 回填 assistant tool_call → 执行 → 回填 tool result随后继续 Tool Loop
fake_id = f"call_{int(time.time() * 1000)}"
self._msgs.append({
"role": "assistant",
"content": None,
"tool_calls": [{
"id": fake_id,
"type": "function",
"function": {"name": pending_tool, "arguments": json.dumps(pending_params, ensure_ascii=False)},
}],
})
yield json.dumps({"type": "tool_call", "tool": pending_tool, "params": pending_params}, ensure_ascii=False) + "\n"
result = await self._execute_tool(pending_tool, pending_params)
if not result.startswith("未知工具") and not result.startswith("ERROR"):
self._tool_call_count += 1
yield json.dumps({"type": "tool_result", "tool": pending_tool, "result": result[:500]}, ensure_ascii=False) + "\n"
self._msgs.append({"role": "tool", "tool_call_id": fake_id, "content": str(result)})
# 不 return继续进入 Tool Loop让 LLM 基于工具结果继续后续步骤
# 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
# 模型不可用等配置错误必须真实报给用户error 事件),
# 绝不能让异常打断流——前端会停在"思考中..."干等。
try:
resp = await self._call_llm()
except Exception as e:
logger.error(f"run: LLM 调用失败 turn={turn+1}: {e}")
yield json.dumps({"type": "error", "message": str(e)}, ensure_ascii=False) + "\n"
return
# 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, tool_params):
if self._session:
self._session.pending_confirm = {"tool": tool_name, "params": tool_params}
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 是合法终止动作,直接输出,不再强制注入工具。
# (历史版本会在 tool_call_count==0 时用硬编码关键词强制路由,
# 那会剥夺 LLM 诚实降级/能力自省的能力,把 agent 变成路由器。)
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 == "ask_user":
# ask_user 是合法终止动作:把问题抛给用户,等待回答。
question = act.get("question", "")
yield json.dumps({
"type": "ask_user", "message": question,
}, ensure_ascii=False) + "\n"
await self._save_turn(user_input, f"[提问] {question}")
return
elif action_type == "tool_call":
tool_name = act.get("tool", "")
tool_params = act.get("params", {})
# 需要确认的工具:记录待确认并暂停,等待用户确认/全部确认/取消
if self._needs_confirmation(tool_name, tool_params):
if self._session:
self._session.pending_confirm = {"tool": tool_name, "params": tool_params}
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):
"""懒加载各组件"""
# 1. 加载 org_id用户机构优先fallback 项目机构)+ workspace_dir + pipeline_id
# 个人选择 llm 按「用户机构」隔离default_llm_id 指向本机构 llm故 org_id 须取 users.orgid
# 项目机构仅作 fallback无 user_id 的测试场景)。
if self.user_id:
try:
from sqlor.dbpools import DBPools
db = DBPools()
async with db.sqlorContext("pipeline") as sor:
recs = await sor.sqlExe(
"SELECT orgid FROM users WHERE id=${uid}$", {"uid": self.user_id})
if recs:
self.org_id = getattr(recs[0], 'orgid', '') or ''
except Exception:
pass
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, workspace_dir, pipeline_id FROM sd_projects WHERE id=${pid}$",
{"pid": self.project_id})
if recs:
if not self.org_id:
self.org_id = getattr(recs[0], 'org_id', '') or ''
ws = getattr(recs[0], 'workspace_dir', '') or ''
if ws:
self.workspace_dir = ws
self.pipeline_id = getattr(recs[0], 'pipeline_id', '') or ''
if not ws:
# 存量项目无 workspace_dir兜底解析项目根目录与上传落盘同一函数
# 保证「上传放的位置 = agent 读的位置」)
from .workspace import get_project_dir_by_id
_pdir, _ = await get_project_dir_by_id(sor, self.project_id)
if _pdir:
self.workspace_dir = _pdir
except Exception:
pass
# 2. 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
# 3. Skill Loader — base_dir 动态解析到机构工作目录(多租户隔离)
try:
from pipeline_core.skill_loader import get_skill_loader
base_dir = await self._resolve_skills_base_dir()
self._skill_loader = get_skill_loader(base_dir)
if base_dir:
self._skill_loader.reload()
except Exception:
self._skill_loader = None
# 4. Memory Store
try:
from pipeline_core.memory_store import get_memory_store
self._memory_store = get_memory_store()
except ImportError:
self._memory_store = None
# 5. pipeline_id 为空时 fallback 到默认产线(保证 slash/ability/skill 统一按产线挂载)
# 入口已指定 default_pipeline_id 时优先用它(投标/开发产线独立入口),否则用引擎默认。
if not self.pipeline_id:
try:
from pipeline_core import DEFAULT_ABILITY_ID
self.pipeline_id = DEFAULT_ABILITY_ID
except ImportError:
pass
# 6. 项目空间键generic→'general'(通用助手),否则用真实 pipeline_id产线之间隔离
self.space = GENERAL_SPACE if self.generic else (self.pipeline_id or GENERAL_SPACE)
async def _resolve_skills_base_dir(self):
"""技能根目录 = 全局 skills/单一技能树所有机构共享读2026-08-21 重构)。"""
try:
from pipeline_core.skill_pack import get_skills_base
return get_skills_base()
except Exception:
return self.config.skills.base_dir
async def _llm_select_skills(self, user_input: str, catalog, max_skills: int) -> List[str]:
"""用 LLM 从技能目录选最相关的 N 个技能(语义匹配,非关键词硬编码)。"""
if not catalog:
return []
catalog_text = "\n".join(f"- {name}: {desc}" for name, desc in catalog)
prompt = (
f"以下是可用技能目录(技能名: 描述):\n{catalog_text}\n\n"
f"用户需求:{user_input}\n\n"
f"请从上述目录中选出与用户需求最相关的至多 {max_skills} 个技能,"
f"只返回技能名的 JSON 数组,如 [\"a\", \"b\"]。没有相关技能返回 []。"
)
try:
from pipeline_service.llm_bridge import llm_call_msgs
import json as _json
import re as _re
content = await llm_call_msgs(
[{"role": "user", "content": prompt}],
model=self.model_name,
temperature=0,
org_id=self.org_id,
)
m = _re.search(r"\[[^\]]*\]", content or "")
if m:
names = _json.loads(m.group(0))
return [n for n in names if isinstance(n, str)]
return []
except Exception as e:
logger.error(f"_llm_select_skills failed: {e}")
return []
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, scope="pipeline", scope_id=self.pipeline_id)
if self.project_id:
proj_block = await self._memory_store.build_prompt_block(
max_entries=5, scope="project", scope_id=self.project_id)
if proj_block:
mem_block = (mem_block + "\n" + proj_block) if mem_block else proj_block
if mem_block:
prompt += f"\n\n## 持久记忆\n{mem_block}"
# 注入技能六级隔离global→org→pipeline→role→project→user
if self.config.skills.enabled and self._skill_loader:
pipeline_id = self.pipeline_id if self.config.skills.enable_pipeline else ""
role = self.role if self.config.skills.enable_role else ""
project_id = self.project_id if self.config.skills.enable_project else ""
org_id = self.org_id if self.config.skills.enable_org else ""
user_id = self.user_id if self.config.skills.enable_user else ""
# 技能检索必须 LLM 做(语义匹配,非关键词硬编码)
catalog = self._skill_loader.get_skill_catalog(
pipeline_id=pipeline_id, role=role, project_id=project_id,
org_id=org_id, user_id=user_id)
names = await self._llm_select_skills(
user_input, catalog, self.config.skills.max_skills_per_turn)
if names:
skill_block = self._skill_loader.build_prompt_block_by_names(
names, pipeline_id=pipeline_id, role=role, project_id=project_id,
org_id=org_id, user_id=user_id)
else:
# LLM 无结果兜底:按 scope+名字列出前 N 个(不传 user_input走 fallback 分支)
skill_block = self._skill_loader.build_prompt_block(
pipeline_id=pipeline_id, role=role, project_id=project_id,
org_id=org_id, user_id=user_id,
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,
org_id=self.org_id,
)
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,
org_id=self.org_id,
)
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始终可用
"""
# 别名归一化LLM 会编造工具名2026-08-31 项目管理的常见变体),
# 映射到真实工具,避免「未知工具」导致用户指令落空。
tool_name = _PROJECT_TOOL_ALIASES.get(tool_name, tool_name)
# 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. 产线能力包工具(从 PipelineAbility 注册表按 pipeline_id 取 handler
result = await self._execute_ability_tool(tool_name, params)
if result is not None:
return result
# 3. 通用内建工具core 内核:项目管理/终端/文件/搜索/会话/规划)
result = await self._execute_sdlc_tool(tool_name, params)
if result is not None:
return result
# 4. ask_user
if tool_name == "ask_user":
return f"QUESTION: {params.get('question', '')}"
return f"未知工具: {tool_name}"
def _build_ctx(self) -> dict:
"""构造传给产线能力 handler / slash 命令的上下文。"""
return {
"project_id": self.project_id,
"user_id": self.user_id,
"org_id": self.org_id,
"pipeline_id": self.pipeline_id,
"space": self.space,
"workspace_dir": self.workspace_dir,
"model_name": self.model_name,
"config": self.config,
}
def _build_slash_ctx(self) -> dict:
"""构造传给 slash 命令 handler 的上下文(含 executor 引用 + role + base_url"""
ctx = self._build_ctx()
ctx["executor"] = self
ctx["role"] = self.role
ctx["base_url"] = getattr(self, "base_url", "") or ""
return ctx
async def _execute_ability_tool(self, tool_name: str, params: dict) -> Optional[str]:
"""产线能力包工具:按 pipeline_id 从 PipelineAbility 注册表取 handler 执行。"""
try:
from pipeline_core import get_ability, DEFAULT_ABILITY_ID
ability = get_ability(self.pipeline_id) or get_ability(DEFAULT_ABILITY_ID)
if not ability or tool_name not in ability.handlers:
return None
from sqlor.dbpools import DBPools
db = DBPools()
async with db.sqlorContext("pipeline") as sor:
return await ability.handlers[tool_name](sor, params, self._build_ctx())
except Exception as e:
logger.error(f"ability tool {tool_name} error: {e}")
return f"ERROR: {str(e)[:300]}"
async def _execute_sdlc_tool(self, tool_name: str, params: dict) -> Optional[str]:
"""通用内建工具core 内核,产线无关)。"""
# 导入现有 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
# 通用内建工具映射core 内核,产线无关)。
# 产线专属工具create_task/diagnose_project 等)已迁移到 sdlc_ability 能力包,
# 由 _execute_ability_tool 按 pipeline_id 动态挂载。
handlers = {
"switch_project": self._t_switch_project,
"create_project": self._t_create_project,
# ── 项目管理(终止/删除类代码层校验人类 owner──
"project_info": self._t_project_info,
"list_my_projects": self._t_list_my_projects,
"pause_project": self._t_pause_project,
"resume_project": self._t_resume_project,
"delete_project": self._t_delete_project,
"run_command": self._t_run_command,
# ── 通用工具集Hermes CLI 能力子集)──
"read_file": self._t_read_file,
"load_skill": self._t_load_skill,
"list_packs": self._t_list_packs,
"install_pack": self._t_install_pack,
"propose_skill": self._t_propose_skill,
"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)
if handler:
return await handler(sor, p, pid)
return None
# ── 工具实现 ──
async def _persist_project(self, sor, pid):
"""持久化当前项目。
- 有 session_id写 pipeline_session_settingsweb 多 tab 各自项目上下文,互不覆盖),
同时写全局 pipeline_agent_settings.current_project_id 作为「最近项目」兜底(供无 session 消费者)。
- 无 session_id只写全局向后兼容如微信通道 / run_agent 直连)。
只改 self.project_id 不够——AgentExecutor 每轮新建run 结束即销毁,
下一轮又从持久层读回旧项目。
"""
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})
# 会话级项目上下文(多 tab 隔离)
if self.session_id:
try:
await sor.sqlExe(
"UPDATE pipeline_session_settings SET current_project_id=${pid}$ "
"WHERE user_id=${uid}$ AND session_id=${sid}$",
{"pid": pid, "uid": self.user_id, "sid": self.session_id})
sexists = await sor.sqlExe(
"SELECT 1 FROM pipeline_session_settings "
"WHERE user_id=${uid}$ AND session_id=${sid}$",
{"uid": self.user_id, "sid": self.session_id})
if not sexists:
await sor.sqlExe(
"INSERT INTO pipeline_session_settings "
"(id, user_id, session_id, current_project_id) "
"VALUES (${id}$, ${uid}$, ${sid}$, ${pid}$)",
{"id": getID(), "uid": self.user_id,
"sid": self.session_id, "pid": pid})
except Exception as e:
logger.warning(f"persist session project failed: {e}")
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}$ AND pipeline_id=${s}$",
{"n": name, "s": self.space})
if recs:
r = recs[0]
self.project_id = getattr(r, "id", "")
await self._persist_project(sor, self.project_id)
await self._refresh_workspace_dir(sor)
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 WHERE pipeline_id=${s}$ ORDER BY created_at DESC LIMIT 20",
{"s": self.space})
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, org_id=self.org_id)
matched = matched.strip().strip('"').strip("'")
recs2 = await sor.sqlExe(
"SELECT id, name FROM sd_projects WHERE name=${n}$ AND pipeline_id=${s}$",
{"n": matched, "s": self.space})
if recs2:
r = recs2[0]
self.project_id = getattr(r, "id", "")
await self._persist_project(sor, self.project_id)
await self._refresh_workspace_dir(sor)
return f"OK: 已切换到 {getattr(r, 'name', matched)}"
except Exception:
pass
return f"未找到项目: {name}"
async def _refresh_workspace_dir(self, sor):
"""切换项目后同步刷新工作目录(与上传落盘/初始化同一解析逻辑)。
不刷新则 list_files/read_file 仍停在切换前的目录(或无项目时的总根)。
"""
try:
from .workspace import get_project_dir_by_id
_pdir, _ = await get_project_dir_by_id(sor, self.project_id)
if _pdir:
import os as _os
_os.makedirs(_pdir, exist_ok=True)
self.workspace_dir = _pdir
except Exception:
pass
async def _t_create_project(self, sor, p, pid):
import os
import re
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"
# 项目专属工作空间目录(统一机制:{base}/{org}/{space}/projects/{项目名}/
# 用名字不用 id创建时定死并写库一切解析只读库不推导
from .workspace import alloc_project_dir
pid_val = getID()
project_dir, slug = await alloc_project_dir(sor, org_id, self.space, name, pid_val)
await sor.C("sd_projects", {
"id": pid_val, "name": name, "description": desc,
"status": "active", "org_id": org_id,
"pipeline_id": self.space, "workspace_dir": project_dir,
"directory_name": slug,
"created_by": self.user_id or "",
"default_model": self.model_name or "",
})
await sor.C("sd_iterations", {
"id": getID(), "project_id": pid_val,
"iteration_name": f"{name}-初始迭代",
"iteration_type": "default", "status": "in_progress", "priority": 1,
"seq_no": 1,
})
self.project_id = pid_val
await self._persist_project(sor, pid_val)
return f"OK: 已创建项目 {name}"
# ── 项目管理工具2026-08-31 新增:会话 agent 获得项目管理能力)──
# 安全模型:终止(暂停)/删除等指令必须来自项目的人类 owner。
# 校验在代码层做_resolve_owned_project 内调 check_project_owner
# 不依赖 prompt——LLM 可以被注入/诱导,但校验函数只认数据库事实。
# owner 定义sd_projects.created_by == 当前登录用户self.user_id
async def _resolve_owned_project(self, sor, p):
"""解析目标项目并校验操作者是否为人类 owner。
project_name 缺省时用当前项目self.project_id给了名称则在当前产线
空间内精确匹配。返回 (project_id, project_name, owner_err)
owner_err 非空 = 无权限或项目不存在,调用方必须原样拒绝。
"""
from .project_capability import check_project_owner
name = (p.get("project_name") or "").strip()
if not name:
pid = self.project_id
if not pid:
return "", "", "当前会话没有选中项目,请先说明要操作哪个项目(或先切换项目)"
recs = await sor.sqlExe(
"SELECT id, name, created_by FROM sd_projects WHERE id=${pid}$",
{"pid": pid})
await sor.sqlExe("COMMIT", {})
if not recs:
return "", "", "当前项目已不存在"
pname = getattr(recs[0], "name", "") or pid
else:
recs = await sor.sqlExe(
"SELECT id, name, created_by FROM sd_projects "
"WHERE name=${n}$ AND pipeline_id=${s}$",
{"n": name, "s": self.space})
await sor.sqlExe("COMMIT", {})
if not recs:
return "", "", f"项目不存在:{name}(当前产线空间 {self.space}"
pid = getattr(recs[0], "id", "")
pname = getattr(recs[0], "name", "") or pid
# 代码层 owner 校验:指令必须来自该项目的人类 owner创建者
ok, err = await check_project_owner(pid, self.user_id or "", sor)
if not ok:
return "", "", f"权限不足:{err}(项目「{pname}」的 owner 才可执行此操作)"
return pid, pname, ""
async def _t_project_info(self, sor, p, pid):
tpid, pname, err = await self._resolve_owned_project(sor, p)
if err:
return f"FAIL: {err}"
recs = await sor.sqlExe(
"SELECT id, name, description, status, pipeline_id, org_id, "
"created_by, created_at, updated_at FROM sd_projects WHERE id=${pid}$",
{"pid": tpid})
await sor.sqlExe("COMMIT", {})
if not recs:
return "FAIL: 项目不存在"
r = recs[0]
lines = [
f"项目:{getattr(r, 'name', '')}",
f"状态:{getattr(r, 'status', '')}",
f"描述:{getattr(r, 'description', '') or '(无)'}",
f"产线:{getattr(r, 'pipeline_id', '') or '(未绑定)'}",
f"创建:{getattr(r, 'created_at', '')} 更新:{getattr(r, 'updated_at', '')}",
]
# 迭代概况
iters = await sor.sqlExe(
"SELECT iteration_name, status FROM sd_iterations WHERE project_id=${pid}$ ORDER BY seq_no",
{"pid": tpid})
await sor.sqlExe("COMMIT", {})
if iters:
lines.append("迭代:" + "".join(
f"{getattr(i, 'iteration_name', '')}({getattr(i, 'status', '')})" for i in iters[:10]))
return "\n".join(lines)
async def _t_list_my_projects(self, sor, p, pid):
# 只列当前用户创建的项目(不暴露他人项目)
uid = self.user_id or ""
if not uid:
return "FAIL: 未登录,无法列出你的项目"
status = (p.get("status") or "").strip()
sql = ("SELECT id, name, status, pipeline_id, created_at FROM sd_projects "
"WHERE created_by=${uid}$")
params = {"uid": uid}
if status:
sql += " AND status=${st}$"
params["st"] = status
sql += " ORDER BY created_at DESC LIMIT 30"
recs = await sor.sqlExe(sql, params)
await sor.sqlExe("COMMIT", {})
if not recs:
return f"你名下没有项目{('(状态=' + status + '') if status else ''}"
lines = []
for r in recs:
lines.append(f"- {getattr(r, 'name', '')} {getattr(r, 'status', '')} "
f"产线 {getattr(r, 'pipeline_id', '') or ''} {getattr(r, 'created_at', '')}")
return f"你创建的项目({len(lines)} 个):\n" + "\n".join(lines)
async def _t_pause_project(self, sor, p, pid):
tpid, pname, err = await self._resolve_owned_project(sor, p)
if err:
return f"FAIL: {err}"
from .project_capability import pause_project
ok, msg = await pause_project(tpid, who=self.user_id, agent_id="cockpit")
if not ok:
return f"FAIL: {msg}"
logger.info("cockpit pause_project: %s by user=%s", tpid, self.user_id)
return f"OK: 项目「{pname}」已暂停(终止推进)。需要时可用 resume_project 恢复。"
async def _t_resume_project(self, sor, p, pid):
tpid, pname, err = await self._resolve_owned_project(sor, p)
if err:
return f"FAIL: {err}"
from .project_capability import resume_project
ok, msg = await resume_project(tpid, who=self.user_id, agent_id="cockpit")
if not ok:
return f"FAIL: {msg}"
logger.info("cockpit resume_project: %s by user=%s", tpid, self.user_id)
return f"OK: 项目「{pname}」已恢复推进。"
async def _t_delete_project(self, sor, p, pid):
confirm = p.get("confirm")
if str(confirm).lower() not in ("true", "1", "yes"):
return ("FAIL: 参数错误,请直接带 confirm=true 调用(平台确认门会弹窗向用户最终确认,"
"无需你先向用户复述确认)。")
tpid, pname, err = await self._resolve_owned_project(sor, p)
if err:
return f"FAIL: {err}"
# 删除后清掉本会话的项目上下文(否则留下悬空指针,下次打开报「请先切换项目」)
from .project_capability import delete_project
ok, msg = await delete_project(tpid, who=self.user_id, agent_id="cockpit", confirm=True)
if not ok:
return f"FAIL: {msg}"
if self.project_id == tpid:
self.project_id = ""
# 清会话级+全局「当前项目」指针_persist_project 对空 pid 直接 return
# 这里直接清,否则留下悬空指针,下次打开报「请先切换项目」)
try:
await sor.sqlExe(
"UPDATE pipeline_session_settings SET current_project_id='' "
"WHERE user_id=${uid}$ AND session_id=${sid}$",
{"uid": self.user_id or "", "sid": self.session_id or ""})
await sor.sqlExe(
"UPDATE pipeline_agent_settings SET current_project_id='' "
"WHERE user_id=${uid}$",
{"uid": self.user_id or ""})
await sor.sqlExe("COMMIT", {})
except Exception as e:
logger.warning(f"clear project pointer after delete failed: {e}")
logger.info("cockpit delete_project: %s by user=%s", tpid, self.user_id)
return f"OK: 项目「{pname}」已删除(含归档备份)。{msg}"
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]}"
def _resolve_ws_path(self, path: str) -> str:
"""解析相对路径为工作空间内绝对路径(越界返回 '')。"""
import os
ws = self.workspace_dir or WORKSPACE_BASE
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}"
ext = os.path.splitext(full)[1].lower()
# docx提取 word/document.xml 文本
if ext == '.docx':
import zipfile
import re as _re
with zipfile.ZipFile(full) as z:
xml = z.read('word/document.xml').decode('utf-8', errors='ignore')
texts = _re.findall(r'<w:t[^>]*>(.*?)</w:t>', xml)
return ('\n'.join(texts)[:30000]) or '(docx 无文本内容)'
# 纯文本类直接读offset 分段 + 截断显式告知——禁止静默截断)
if ext in ('.txt', '.md', '.json', '.csv', '.py', '.log', '.yaml', '.yml', '.xml', '.html', '.ini', ''):
offset = int(p.get("offset") or 0)
with open(full, encoding="utf-8", errors="ignore") as f:
full_txt = f.read()
chunk = full_txt[offset:offset + 30000]
if offset + 30000 < len(full_txt):
chunk += (f"\n\n[⚠️ 截断提示:以上为第 {offset}~{offset + len(chunk.rstrip())} 字符,"
f"全文共 {len(full_txt)} 字符。读后文请再次 read_file 并传 offset={offset + 30000}。]")
return chunk
# 其他二进制
return f"该文件是二进制格式({ext or '无扩展名'}),无法直接读取文本。可改用 run_command 处理,或让用户上传文本版本。"
except Exception as e:
return f"ERROR: {str(e)[:300]}"
async def _t_load_skill(self, sor, p, pid):
name = (p.get("name") or "").strip()
if not name:
return "FAIL: 需要技能名称"
if not self._skill_loader:
return "FAIL: 技能系统未初始化"
merged = self._skill_loader.get_merged(
pipeline_id=self.pipeline_id or "",
role=self.role or "",
project_id=self.project_id or "",
org_id=self.org_id or "",
user_id=self.user_id or "")
from pipeline_core.skill_loader import resolve_skill, format_skill_names
skill = resolve_skill(merged, name)
if skill is None:
names = format_skill_names(merged)
return (f"FAIL: 技能 '{name}' 不存在。注意name 只传技能裸名"
f"(如 bid-workflow不要带 [产线] 前缀或描述文字。可用技能: {names}")
file_path = (p.get("file_path") or "").strip()
if file_path:
body = skill.read_linked_file(file_path)
if body.startswith("FAIL") or body.startswith("ERROR"):
linked = skill.list_linked_files()
return f"{body}\n可用子文件: {', '.join(linked) if linked else '(无)'}"
return f"## [{skill.name}] {file_path}\n\n{body}"
block = skill.to_prompt_block()
linked = skill.list_linked_files()
if linked:
block += f"\n(可用子文件,用 file_path 参数加载:{', '.join(linked)}\n"
return block
async def _t_list_packs(self, sor, p, pid):
try:
from pipeline_core.skill_pack import list_packs
packs = list_packs()
if not packs:
return "(无可安装的技能集)"
lines = ["可安装的技能集:"]
for pk in packs:
lines.append(f" - {pk['name']}: {pk.get('title', '')}{pk.get('skill_count', 0)} 个技能)")
return "\n".join(lines)
except Exception as e:
return f"ERROR: {str(e)[:300]}"
async def _t_install_pack(self, sor, p, pid):
pack = (p.get("pack") or "").strip()
if not pack:
return "FAIL: 需要技能集名(先用 list_packs 查看可用的技能集名)"
try:
from pipeline_core.skill_pack import install_pack
base_dir = self._skill_loader.base_dir if self._skill_loader else ""
org_id = self.org_id or "0"
r = install_pack(pack, base_dir, org_id)
if r.get("success"):
if self._skill_loader:
self._skill_loader.reload()
installed = r.get("installed", [])
return f"OK: 已安装技能集 {pack}{len(installed)} 个技能)"
return f"FAIL: {r.get('error', '未知错误')}"
except Exception as e:
return f"ERROR: {str(e)[:300]}"
async def _t_propose_skill(self, sor, p, pid):
name = (p.get("name") or "").strip()
description = (p.get("description") or "").strip()
content = (p.get("content") or "").strip()
if not name or not content:
return "FAIL: 需要技能名和内容content 为 SKILL.md 草稿正文)"
try:
from appPublic.uniqueID import getID
await sor.C("skill_proposals", {
"id": getID(),
"name": name,
"description": description,
"content": content,
"source": "agent",
"status": "pending",
"org_id": self.org_id or "",
"pipeline_id": self.pipeline_id or "",
"created_by": self.user_id or "",
})
return f"OK: 已提交技能提议 '{name}'(待审核,暂不生效)"
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]}"
# ═══════════════════════════════════════════════════════
# 上下文压缩
# ═══════════════════════════════════════════════════════
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,
org_id=self.org_id,
)
return summary[:500]
except Exception:
return text[:500]
# ═══════════════════════════════════════════════════════
# 会话管理
# ═══════════════════════════════════════════════════════
async def _load_history(self, external_history: List[dict] = None) -> List[dict]:
"""加载历史消息。隔离策略session_id > project 级别。
多 tab 独立会话session_id 非空时按 session_id 隔离(每个 tab 独立历史);
否则退回 config.session_isolationproject/user/none
"""
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:
# 会话级隔离web 多 tab 独立会话)
# 注意2026-09-01 修复):必须 ORDER BY created_at DESC LIMIT N 取「最新」N 条
# 再倒序回时间正序。历史用 ASC LIMIT 会取「最旧」N 条——会话消息超过
# history_limit 后,最近的提问(如 A/B/C 选项被截掉LLM 看不到刚问的
# 问题,按更旧的上下文答非所问(用户答 Cagent 复读 4 小时前的旧回复)。
if self.session_id:
# 产线隔离2026-08-31 修复跨产线串扰):各产线页面默认 tab 共用
# session_id='default',历史若不按产线过滤,投标页会加载开发页的
# 对话(如"元景项目/新建demo项目"LLM 跟着最后上下文答错产线。
sql = ("SELECT role, content FROM pipeline_conversations "
"WHERE session_id=${sid}$ AND created_by=${uid}$ ")
params = {"sid": self.session_id, "uid": self.user_id or "",
"lim": self.config.history_limit}
if self.pipeline_id and not self.generic:
sql += "AND pipeline_id=${pl}$ "
params["pl"] = self.pipeline_id
sql += "ORDER BY created_at DESC LIMIT ${lim}$"
recs = await sor.sqlExe(sql, params)
else:
# 项目隔离
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 DESC 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 DESC LIMIT ${lim}$",
{"uid": self.user_id or "", "lim": self.config.history_limit},
)
if not recs:
return []
recs = list(reversed(recs)) # DESC 取出 → 倒回时间正序
# 过滤 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:
# 产线隔离2026-08-31generic 会话不挂产线标签——generic 的
# pipeline_id 会回退 DEFAULT_ABILITY_ID(sdlc_general),若落库会污染
# 开发产线历史。存空串 = 通用对话,各产线读取时按产线过滤自然排除。
store_pl = "" if self.generic else (self.pipeline_id or "")
# 用户消息
await sor.C("pipeline_conversations", {
"id": getID(),
"role": "user",
"content": user_input,
"created_by": self.user_id or "",
"iteration_id": self.project_id or "",
"session_id": self.session_id or "",
"pipeline_id": store_pl,
})
# 助手回复(不含 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 "",
"session_id": self.session_id or "",
"pipeline_id": store_pl,
})
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 _is_dangerous_command(self, cmd: str) -> bool:
"""判断 shell 命令是否危险run_command 只对危险命令要求确认)。"""
c = (cmd or "").strip().lower()
dangerous = (
"rm -rf", "rm -r", "sudo", "su ", "drop table", "drop database",
"truncate", "delete from", "mkfs", "dd if", ":(){", "shutdown",
"reboot", "kill -9", "chmod 777", "chown", "> /dev/",
)
return any(d in c for d in dangerous)
def _detect_confirm_decision(self, user_input: str) -> str:
"""检测用户对危险命令确认的回复。返回 approve / approve_all / cancel / ''"""
t = (user_input or "").strip().lower()
if not t:
return ""
for kw in ("全部确认", "确认全部", "全部同意", "都确认", "都同意", "一律确认", "approve all", "yes to all", "always"):
if kw in t:
return "approve_all"
for kw in ("取消", "放弃", "不执行", "别执行", "cancel", "abort", "不要"):
if kw in t:
return "cancel"
for kw in ("确认", "同意", "执行", "可以", "approve", "confirm", "yes", "ok", "继续"):
if kw in t:
return "approve"
return ""
def _needs_confirmation(self, tool_name: str, params: dict = None) -> bool:
"""检查工具是否需要用户确认。
会话级「全部确认」approve_all时直接放行否则 run_command 只对
危险命令确认,普通命令自动执行,其他 requires_confirmation 工具一律确认。
别名先归一再查注册表2026-08-31 修复):否则 LLM 用别名(如 stop_project
调需确认工具时,注册表查不到 → 误判无需确认 → 绕过确认门直接执行。
"""
tool_name = _PROJECT_TOOL_ALIASES.get(tool_name, tool_name)
if self._session and getattr(self._session, "approve_all", False):
return False
if self._tool_registry:
tool = self._tool_registry.get(tool_name)
if tool and tool.requires_confirmation:
if tool_name == "run_command":
cmd = (params or {}).get("command", "")
return self._is_dangerous_command(cmd)
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