""" 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 os import time from typing import AsyncGenerator, Dict, List, Optional logger = logging.getLogger("pipeline.agent_executor") 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-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, role: str = "", # 产线内角色(可选,如 develop/design;驾驶舱 agent 为空) base_url: str = "", # 请求 base_url(scheme://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 # GatewaySession(approve_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) 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_calls,OpenAI 原生格式要求) 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 '' except Exception: pass # 2. Tool Registry try: from pipeline_core.tool_registry import get_tool_registry self._tool_registry = get_tool_registry() # 把 config.tools 注册进 registry(registry 是全局单例,可能为空) 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(始终可用) """ # 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, "pipeline_id": self.pipeline_id, "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, "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_settings(web 多 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) 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) return f"OK: 已切换到 {getattr(r, 'name', matched)}" except Exception: pass return f"未找到项目: {name}" 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/{英文slug}/, # 与 apps/、modules/ 平级;工作空间控件按此结构读取)。 # 英文 slug:项目名保留 [A-Za-z0-9_-],且须含字母才有辨识度(避免纯数字如 "7"); # 纯中文或纯数字名兑底 proj_{短ID}。 from .workspace import get_workspace_base, build_space_path workspace_base = await get_workspace_base(sor) pid_val = getID() space_dir = build_space_path(workspace_base, org_id, self.space) slug = re.sub(r'[^A-Za-z0-9_-]', '', name).strip('_-') if not slug or not re.search(r'[A-Za-z]', slug): slug = 'proj_' + pid_val[-8:] project_dir = os.path.join(space_dir, 'projects', slug) if os.path.isdir(project_dir): # 同 slug 已存在(不同项目重名)→ 追加短 ID,避免共享目录互相覆盖 slug = f"{slug}_{pid_val[-8:]}" project_dir = os.path.join(space_dir, 'projects', slug) os.makedirs(project_dir, exist_ok=True) 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}" 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']*>(.*?)', xml) return ('\n'.join(texts)[:30000]) or '(docx 无文本内容)' # 纯文本类:直接读 if ext in ('.txt', '.md', '.json', '.csv', '.py', '.log', '.yaml', '.yml', '.xml', '.html', '.ini', ''): with open(full, encoding="utf-8", errors="ignore") as f: return f.read()[:30000] # 其他二进制 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 "") if name not in merged: names = ", ".join(sorted(merged.keys())) or "(无可用技能)" return f"FAIL: 技能 '{name}' 不存在。可用技能: {names}" return merged[name].to_prompt_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_isolation(project/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 独立会话) if self.session_id: recs = await sor.sqlExe( "SELECT role, content FROM pipeline_conversations " "WHERE session_id=${sid}$ AND created_by=${uid}$ " "ORDER BY created_at ASC LIMIT ${lim}$", {"sid": self.session_id, "uid": self.user_id or "", "lim": self.config.history_limit}, ) 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 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 "", "session_id": self.session_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 "", "session_id": self.session_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 _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 工具一律确认。 """ 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