1285 lines
54 KiB
Python
1285 lines
54 KiB
Python
"""
|
||
pipeline-service v2: Agent Executor — 智能执行引擎
|
||
|
||
对照 Hermes Agent 的 run_conversation() + tool dispatch:
|
||
- 多轮 tool-calling loop,max_turns 可配置
|
||
- Memory + Skills 注入每轮 system prompt
|
||
- 上下文压缩(近 token 上限时自动压缩)
|
||
- 并行 tool calls(同一轮内独立工具同时执行)
|
||
- 消息数组污染防护(tool_call JSON 不进入 LLM 上下文)
|
||
- 会话持久化(用户+项目隔离)
|
||
- ask_user 澄清机制
|
||
- delegate_subtask 子任务分发
|
||
|
||
使用方法:
|
||
executor = AgentExecutor(config, project_id=..., user_id=...)
|
||
async for chunk in executor.run("用户输入"):
|
||
yield chunk # NDJSON 流式输出
|
||
"""
|
||
|
||
import asyncio
|
||
import json
|
||
import logging
|
||
import os
|
||
import time
|
||
from typing import AsyncGenerator, Dict, List, Optional
|
||
|
||
logger = logging.getLogger("pipeline.agent_executor")
|
||
|
||
|
||
# ── 默认工具定义(在 pipeline-core 未加载时使用)──
|
||
|
||
_BUILTIN_ASK_USER_SCHEMA = {
|
||
"name": "ask_user",
|
||
"description": "向用户提问澄清意图。不确定用户想要什么时使用。",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"question": {"type": "string", "description": "要问的问题"},
|
||
},
|
||
"required": ["question"],
|
||
},
|
||
}
|
||
|
||
|
||
class AgentExecutor:
|
||
"""Agent 执行引擎。
|
||
|
||
负责:
|
||
1. 组装 system prompt(配置 + 记忆 + 技能 + 工具)
|
||
2. 运行 tool-loop(LLM 决策 → 工具执行 → 回传结果)
|
||
3. 上下文压缩
|
||
4. 会话持久化
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
config, # AgentConfig (from pipeline_core)
|
||
project_id: str = "",
|
||
user_id: str = "",
|
||
workspace_dir: str = "",
|
||
model_name: str = None,
|
||
role: str = "", # 产线内角色(可选,如 develop/design;驾驶舱 agent 为空)
|
||
):
|
||
self.config = config
|
||
self.project_id = project_id
|
||
self.user_id = user_id
|
||
self.org_id = "" # loaded from project context
|
||
self.pipeline_id = "" # loaded from project context(产线能力包 key)
|
||
self.role = role # 产线内角色(技能/工具/prompt 按角色加载)
|
||
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._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 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):
|
||
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):
|
||
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 + workspace_dir + pipeline_id(从项目上下文,需先于 skill 加载)
|
||
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:
|
||
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 统一按产线挂载)
|
||
if not self.pipeline_id:
|
||
try:
|
||
from pipeline_core import DEFAULT_ABILITY_ID
|
||
self.pipeline_id = DEFAULT_ABILITY_ID
|
||
except ImportError:
|
||
pass
|
||
|
||
async def _resolve_skills_base_dir(self):
|
||
"""技能根目录 = 机构工作目录/skills/(多租户隔离,不在全局应用目录)。"""
|
||
try:
|
||
from sqlor.dbpools import DBPools
|
||
from pipeline_service.workspace import get_workspace_base
|
||
from pipeline_core.skill_pack import ensure_org_skills
|
||
db = DBPools()
|
||
async with db.sqlorContext("pipeline") as sor:
|
||
ws_base = await get_workspace_base(sor)
|
||
org_id = self.org_id or '0'
|
||
return ensure_org_skills(ws_base, org_id)
|
||
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,
|
||
)
|
||
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,
|
||
)
|
||
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. 产线能力包工具(从 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)。"""
|
||
ctx = self._build_ctx()
|
||
ctx["executor"] = self
|
||
ctx["role"] = self.role
|
||
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):
|
||
"""持久化当前项目到 pipeline_agent_settings(user_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_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 "/tmp/pipeline_ws"
|
||
full = os.path.abspath(os.path.join(ws, path or "."))
|
||
# 限制在 workspace 内
|
||
if full != ws and not full.startswith(ws.rstrip("/") + "/"):
|
||
return ""
|
||
return full
|
||
|
||
async def _t_read_file(self, sor, p, pid):
|
||
path = p.get("path", "")
|
||
if not path:
|
||
return "FAIL: 需要文件路径"
|
||
full = self._resolve_ws_path(path)
|
||
if not full:
|
||
return f"FAIL: 路径越界 {path}"
|
||
try:
|
||
if not os.path.isfile(full):
|
||
return f"FAIL: 文件不存在 {path}"
|
||
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 无文本内容)'
|
||
# 纯文本类:直接读
|
||
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,
|
||
)
|
||
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 _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 _needs_confirmation(self, tool_name: str, params: dict = None) -> bool:
|
||
"""检查工具是否需要用户确认。run_command 只对危险命令确认,普通命令自动执行。"""
|
||
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
|