2630 lines
133 KiB
Python
2630 lines
133 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")
|
||
|
||
# 项目管理工具别名表: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
|
||
|
||
# 产线/项目管理类工具(category=='project')。产线隔离铁律:纯通用会话
|
||
# (无产线插件)绝不挂载——否则通用助手能看到并操作其他产线的项目
|
||
# (2026-09-05 修复)。过滤同时作用于:① schema(LLM 看不见)
|
||
# ② 执行层(看见也拒绝),纵深防御。
|
||
_PROJECT_TOOL_NAMES = {
|
||
"switch_project", "create_project", "project_info", "list_my_projects",
|
||
"pause_project", "resume_project", "delete_project",
|
||
}
|
||
|
||
# 产线耦合工具拦截(2026-09-08 更新):run_command 已从 generic 拒绝名单移除——
|
||
# strict bwrap 档(不挂平台目录、可写根=用户 _general/{uid})根治了「shell 枚举
|
||
# 全工作空间」的泄漏路径,_t_run_command 对 generic 强制 strict=True,无 bwrap 拒绝。
|
||
# 名单保留机制本身,未来若有无法沙箱化的工具仍可加入。
|
||
_GENERIC_DENIED_TOOLS = set()
|
||
|
||
# 用户敏感信息库工具名(2026-09-17):与 pipeline_core.agent_config.GENERAL_TOOLS 里
|
||
# category="secret" 的工具一一对应,与 secret_vault.SECRET_HANDLERS 的键一致。
|
||
# 三处同步:工具定义(core) + handlers 映射(本文件 _dispatch_sdlc_tool) + handler 实现(secret_vault)。
|
||
_SECRET_TOOL_NAMES = (
|
||
"list_secrets", "save_secret", "use_secret",
|
||
"delete_secret", "set_secret_status", "detect_secret",
|
||
)
|
||
|
||
|
||
# ── 默认工具定义(在 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. 会话持久化
|
||
"""
|
||
|
||
# 收尾预留轮次(2026-09-11):轮次预算剩这么多轮时注入「立即收尾」指令,
|
||
# 强制 LLM 停止调工具、基于已有材料输出最终结论。根因见 run() 内 5-pre0 段注释
|
||
#(v1 有 _FORCE_PRODUCE_HINT、v2 此前缺等价机制 → 长任务撞穿 max_turns 后
|
||
# 静默退出,用户只拿到中间进度句)。
|
||
_WRAP_UP_RESERVE = 3
|
||
|
||
# 辅助调用(压缩摘要/技能选择/意图分类)超时上限(2026-09-11)。
|
||
# 这些是「可降级」调用:失败有兜底(摘要退化为截断文本、技能选择退化为目录层)。
|
||
# 给短超时让它们快速失败走兜底,而不是占用端点默认 300s × 3 重试把整轮卡死
|
||
#(实测上游抖动窗口内 utility 调用连烧 15 分钟才放弃,会话全程无响应)。
|
||
_UTILITY_TIMEOUT = 60
|
||
|
||
# 续跑门禁(2026-09-11 商机产线实测):模型输出纯文本「意图句」就结束 turn、
|
||
# 不调工具 → 循环误判为最终答案终止会话,用户拿到半截话(实测连续 4 个会话
|
||
# 在 tool_call_count 7~11 时这样夭折,报告/总结从未产出)。
|
||
# 门禁条件:已有工作推进(tool_call_count>0) 且回复短于 _CONTINUE_GATE_MIN_CHARS
|
||
#(真结论/报告远长于此)且催促未达上限 → 注入续跑指令继续循环;
|
||
# 达上限后尊重模型终止(防死循环)。
|
||
_CONTINUE_GATE_MIN_CHARS = 400
|
||
_CONTINUE_GATE_MAX = 2
|
||
|
||
# 主调用单次超时(2026-09-11):端点缺省 330s 太长——上游超时窗内一次失败
|
||
# 要烧 330s×3 重试。实测最长成功响应 3212 token <60s,180s 留 3 倍余量足够,
|
||
# 失败快速返回交给会话级重试跨越窗口。
|
||
_MAIN_TIMEOUT = 180
|
||
# 会话级重试次数(含首次共 3 次尝试)+ 退避秒数:上游超时窗实测分钟级、
|
||
# 间歇出现;3 次尝试 × 180s + 2×15s 退避 ≈ 9.5 分钟跨度,可跨越多数窗口。
|
||
_MAIN_ATTEMPTS = 3
|
||
_MAIN_RETRY_BACKOFF = 15
|
||
|
||
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)
|
||
delegate_depth: int = 0, # 委派深度(0=顶层会话;子agent=1,禁止再委派)
|
||
pii_session_key: str = "", # PII ephemeral 缓存键(gateway _session_key;空=无 PII 层)
|
||
):
|
||
self.config = config
|
||
self.project_id = project_id
|
||
self.user_id = user_id
|
||
self.pii_session_key = pii_session_key
|
||
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}]
|
||
# ── 委派运行时(2026-09-10)──
|
||
self._delegate_depth: int = delegate_depth # 子agent不能再委派(subagents.MAX_DEPTH)
|
||
self._subagent_id: str = "" # 本 executor 若是后台子agent,其 id(心跳/steer/stop 钩子用)
|
||
# ── 五级作用域工具解析结果(2026-09-10)──
|
||
self._has_images: bool = False # 本轮消息含图片 parts(LLM 失败降级重试用)
|
||
self._allowed_tools: Optional[set] = None # None=未解析(回退旧行为);set=执行层门禁允许集
|
||
self._capability_tool_names: set = set() # 技能 frontmatter 声明的 capability 工具名
|
||
self._tool_scope_trace: List[str] = [] # 诊断轨迹(/tools slash 可展示)
|
||
|
||
# 懒加载
|
||
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,
|
||
image_parts: List[dict] = None) -> AsyncGenerator[str, None]:
|
||
"""执行 agent 主循环。
|
||
|
||
Args:
|
||
user_input: 用户输入
|
||
history: 历史消息列表 [{"role":"user"|"assistant","content":"..."}]
|
||
image_parts: 上传图片的 OpenAI 多模态 parts(upload_tools.build_image_parts
|
||
产物)。非空时首条用户消息构造为多模态 content 数组
|
||
[{"type":"text",...}, {"type":"image_url",...}]——
|
||
llm_bridge → llm_v1 端点 → inference 全链原样透传 messages,
|
||
视觉模型直接看图;非视觉模型由 _call_llm 的降级重试兜底。
|
||
|
||
Yields:
|
||
NDJSON 行:{"type":"progress"|"tool_call"|"reply"|"error", ...}
|
||
"""
|
||
self._started_at = time.time()
|
||
self._turn_count = 0
|
||
self._tool_call_count = 0
|
||
self._continue_nudges = 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)
|
||
if image_parts:
|
||
# 原生视觉(2026-09-10):OpenAI 多模态 content 数组。
|
||
# 文本部分放最前,图片 parts 依次跟随;_has_images 标记供降级重试用。
|
||
self._msgs.append({"role": "user", "content":
|
||
[{"type": "text", "text": user_input}] + list(image_parts)})
|
||
self._has_images = True
|
||
else:
|
||
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
|
||
# 显示层上限(2026-09-15 用户要求完整显示,替代旧硬截 [:500]):
|
||
# 默认 50000 字符 = 正常工具输出完整显示;超限显式告知(绝不静默截断)。
|
||
from .result_cap import cap_tool_result, cap_display_result, resolve_max_chars, resolve_display_max_chars
|
||
yield json.dumps({"type": "tool_result", "tool": pending_tool,
|
||
"result": cap_display_result(result, await resolve_display_max_chars())}, ensure_ascii=False) + "\n"
|
||
# 回填上限(2026-09-11):工具结果全文回填会撑爆上下文(商机产线实测
|
||
# 8 次数据工具 ~78K token → 压缩摘要自身超时 → 会话零产出)。
|
||
# 截断显式告知 + 给缩小范围指引,让 LLM 自己改策略。
|
||
_capped, _ = cap_tool_result(result, await resolve_max_chars())
|
||
self._msgs.append({"role": "tool", "tool_call_id": fake_id, "content": _capped})
|
||
# 不 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
|
||
|
||
# 5-pre0. 收尾门禁(2026-09-11 商机产线实测教训):轮次预算接近耗尽时
|
||
# 注入「立即收尾」指令,强制 LLM 用已有材料给出最终结论。
|
||
# 根因:v1 角色 agent 有 _FORCE_PRODUCE_HINT 打断探索循环,v2 会话 agent
|
||
# 此前没有等价机制——多工具长任务(如商机调研→功能点估算→成本→总结)
|
||
# 会跑满 max_turns 后静默退出,用户只看到中间进度句(「功能点已算出…」),
|
||
# 拿不到要求的总结。收尾轮不再允许调工具,只准产出最终回答。
|
||
_wrap_turn = max_turns - self._WRAP_UP_RESERVE
|
||
if turn == _wrap_turn:
|
||
self._msgs.append({
|
||
"role": "user",
|
||
"content": (
|
||
"⚠️【收尾指令】你的对话轮次即将用尽(仅剩 %d 轮)。"
|
||
"立即停止调用任何工具,基于已经获取的材料直接给出**完整的最终回答**:"
|
||
"把用户要求的每一项都答到(数据/结论/建议/总结),"
|
||
"信息不全的部分如实标注「数据未覆盖」并给出你的专业判断,"
|
||
"不要再做探索、核对或补数。现在就输出最终结论。" % (max_turns - turn)
|
||
),
|
||
})
|
||
logger.info(f"wrap-up hint injected at turn {turn+1}/{max_turns}")
|
||
|
||
# 5-pre. 子 agent 运行时钩子(2026-09-10):心跳 + steer 消费 + stop 检测。
|
||
# 后台委派的子 executor 带 _subagent_id,每轮 tool-loop 边界:
|
||
# - heartbeat 刷新 meta.updated_at(stale 判活依据)
|
||
# - steer.txt 有内容 → 作为带外父指令注入消息(消费后删除)
|
||
# - stop.flag 存在 → 优雅停止(已有部分结果由 subagents._runner 保留)
|
||
if self._subagent_id:
|
||
try:
|
||
from . import subagents as _sa
|
||
steer_msgs, stop_req = _sa.heartbeat(
|
||
self.workspace_dir, self._subagent_id)
|
||
except Exception as e:
|
||
logger.warning(f"subagent hook failed: {e}")
|
||
steer_msgs, stop_req = [], False
|
||
if stop_req:
|
||
msg = "(子任务被父agent终止,已保留部分结果)"
|
||
yield json.dumps({"type": "reply", "message": msg}, ensure_ascii=False) + "\n"
|
||
return
|
||
for sm in steer_msgs:
|
||
self._msgs.append({
|
||
"role": "user",
|
||
"content": ("[带外指令——来自父agent的中途纠正,与用户消息同等效力,"
|
||
f"立即调整方向] {sm}"),
|
||
})
|
||
|
||
# 5a. 上下文压缩
|
||
if self.config.compression.enabled:
|
||
await self._maybe_compress()
|
||
|
||
# 5b. LLM 调用(native function calling,返回 dict)
|
||
# 模型不可用等配置错误必须真实报给用户(error 事件),
|
||
# 绝不能让异常打断流——前端会停在"思考中..."干等。
|
||
# 会话级重试(2026-09-11):上游瞬时抖动(TimeoutError/ClientError)时
|
||
# 重试一次再放弃。实测上游存在分钟级抖动窗口(同模型同上下文直调成功、
|
||
# 产线调用 TimeoutError 3 连败),单次失败即终止会话代价过高(整轮工作丢失)。
|
||
# 重试前向用户透出进度,避免"卡死"观感。
|
||
resp = None
|
||
_last_err = None
|
||
for _attempt in range(self._MAIN_ATTEMPTS):
|
||
try:
|
||
resp = await self._call_llm()
|
||
break
|
||
except Exception as e:
|
||
_last_err = e
|
||
_transient = any(k in type(e).__name__ or k in str(e)
|
||
for k in ("Timeout", "ClientError", "ServerDisconnected",
|
||
"ConnectionReset"))
|
||
if not _transient or _attempt >= self._MAIN_ATTEMPTS - 1:
|
||
break
|
||
logger.warning(f"run: LLM 瞬时失败 turn={turn+1} 重试{_attempt+1}: {e}")
|
||
yield json.dumps({
|
||
"type": "progress",
|
||
"message": "模型上游瞬时超时,正在重试…\n",
|
||
}, ensure_ascii=False) + "\n"
|
||
await asyncio.sleep(self._MAIN_RETRY_BACKOFF)
|
||
if resp is None:
|
||
logger.error(f"run: LLM 调用失败 turn={turn+1}: {_last_err}")
|
||
yield json.dumps({"type": "error", "message": str(_last_err)}, 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_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
|
||
|
||
# 完备性/澄清门禁硬拦截(2026-09-08 用户定夺:一C+二A):
|
||
# 工具返回 QUESTION: 前缀 = 平台判定「缺完成任务的必备输入」,
|
||
# 已停在花钱动作之前(上游未调用、零费用)。必须把它当 ask_user
|
||
# 直接抛给用户等回答,禁止回填后靠 LLM 自觉转达(deepseek 类
|
||
# 模型会无视、继续空参硬试烧钱)。ask_user 工具同走此前缀,语义统一。
|
||
if isinstance(result, str) and result.startswith("QUESTION:"):
|
||
question = result[len("QUESTION:"):].strip()
|
||
yield json.dumps({
|
||
"type": "ask_user", "message": question,
|
||
}, ensure_ascii=False) + "\n"
|
||
await self._save_turn(user_input, f"[提问] {question}")
|
||
return
|
||
|
||
# 显示层上限(同 Step 4.5,默认 50000 = 完整显示;超限显式告知)
|
||
from .result_cap import cap_display_result, resolve_display_max_chars
|
||
yield json.dumps({
|
||
"type": "tool_result", "tool": tool_name,
|
||
"result": cap_display_result(result, await resolve_display_max_chars()),
|
||
}, ensure_ascii=False) + "\n"
|
||
|
||
# 原生回填:role=tool + tool_call_id
|
||
# 回填上限:防数据类工具全文回填撑爆上下文(见 result_cap 模块 docstring)
|
||
from .result_cap import cap_tool_result, resolve_max_chars
|
||
_capped, _trunc = cap_tool_result(result, await resolve_max_chars())
|
||
if _trunc:
|
||
logger.info(f"tool_result truncated before refill: {tool_name}")
|
||
self._msgs.append({
|
||
"role": "tool",
|
||
"tool_call_id": tc.get("id", "") if isinstance(tc, dict) else "",
|
||
"content": _capped,
|
||
})
|
||
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", "")
|
||
|
||
# 续跑门禁(2026-09-11):已有工作推进但回复是短意图句
|
||
#("我再拉一次数据…")= 模型提前结束 turn 而非真结论。
|
||
# 注入续跑指令继续循环,不让会话夭折在半截话上。
|
||
if (self._tool_call_count > 0
|
||
and len(final_reply) < self._CONTINUE_GATE_MIN_CHARS
|
||
and self._continue_nudges < self._CONTINUE_GATE_MAX):
|
||
self._continue_nudges += 1
|
||
logger.warning(
|
||
f"run: 续跑门禁 turn={turn+1} 短回复({len(final_reply)}字)"
|
||
f" tool_calls={self._tool_call_count} 催促{self._continue_nudges}次")
|
||
yield json.dumps({
|
||
"type": "progress",
|
||
"message": "检测到未完成的中途回复,继续推进任务…\n",
|
||
}, ensure_ascii=False) + "\n"
|
||
self._msgs.append({"role": "assistant", "content": final_reply})
|
||
self._msgs.append({"role": "user", "content": (
|
||
"【续跑指令】你刚才的回复是中途进度句,不是最终答案,"
|
||
"任务尚未完成(用户要求的产出还没交付)。"
|
||
"请继续执行:要么调用工具推进下一步,"
|
||
"要么直接输出完整的最终结论/报告(含全部要求的分析项)。"
|
||
"不要只说下一步打算做什么。")})
|
||
continue
|
||
|
||
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
|
||
|
||
# 完备性/澄清门禁硬拦截(同 native 路径,2026-09-08 一C+二A)
|
||
if isinstance(result, str) and result.startswith("QUESTION:"):
|
||
question = result[len("QUESTION:"):].strip()
|
||
yield json.dumps({
|
||
"type": "ask_user", "message": question,
|
||
}, ensure_ascii=False) + "\n"
|
||
await self._save_turn(user_input, f"[提问] {question}")
|
||
return
|
||
|
||
# 显示层上限(同 native 路径,默认 50000 = 完整显示;超限显式告知)
|
||
from .result_cap import cap_display_result, resolve_display_max_chars
|
||
yield json.dumps({
|
||
"type": "tool_result", "tool": tool_name,
|
||
"result": cap_display_result(result, await resolve_display_max_chars()),
|
||
}, 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
|
||
# 产线隔离(2026-09-05):必须每个 executor 独立实例,禁止全局单例。
|
||
# 全局单例会让先启动的产线会话注册的工具(如 sdlc 的 list_projects)
|
||
# 永久残留,之后通用会话把全部工具塞给 LLM → 通用助手看到并调用
|
||
# 其他产线的工具。config.tools 已由 load_agent_config 按会话裁剪,
|
||
# 这里只注册本会话自己的工具。
|
||
try:
|
||
from pipeline_core.tool_registry import ToolRegistry
|
||
self._tool_registry = ToolRegistry()
|
||
if self.config.tools:
|
||
for t in self.config.tools:
|
||
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 时优先用它(投标/开发产线独立入口),否则用引擎默认。
|
||
# 产线隔离(2026-09-05):纯通用会话(generic)绝不回退默认产线——
|
||
# 回退会让 _execute_ability_tool 拿到 sdlc_general 能力包,通用助手
|
||
# 就能调用 list_projects 等产线工具。
|
||
if not self.pipeline_id and not self.generic:
|
||
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)
|
||
|
||
# 7. 通用会话专属工作目录(2026-09-05 第 6 层泄漏修复):默认文件根是
|
||
# WORKSPACE_BASE 总根({base}/{org}/{产线}/… 全量可见),LLM 用
|
||
# list_files/read_file 就能从目录名枚举所有产线的项目。通用会话改用
|
||
# 用户专属目录 _general/{user_id},_resolve_ws_path 越界保护把它圈死。
|
||
if self.generic:
|
||
from .workspace import generic_workspace_dir
|
||
self.workspace_dir = generic_workspace_dir(self.user_id)
|
||
try:
|
||
os.makedirs(self.workspace_dir, exist_ok=True)
|
||
except Exception:
|
||
pass
|
||
|
||
# 8. 五级作用域工具解析(2026-09-10):global(GENERAL_TOOLS) → org(策略+机构技能包)
|
||
# → pipeline(能力包+产线策略) → role(RoleSpec 白名单+角色策略) → project(策略微调)。
|
||
# 产出:registry 重建为解析后工具集 + _allowed_tools 执行层门禁
|
||
# (LLM 幻觉调用作用域外工具名 → 直接拒绝,schema 防线挡不住幻觉)。
|
||
try:
|
||
await self._resolve_tool_scopes()
|
||
except Exception as e:
|
||
# 解析失败不阻断会话:回退旧行为(registry 保持 config.tools 全量,无门禁)
|
||
logger.warning(f"tool scope resolve failed, fallback to base tools: {e}")
|
||
self._allowed_tools = None
|
||
|
||
async def _resolve_tool_scopes(self):
|
||
"""五级作用域工具解析(pipeline_core.tool_sources)。
|
||
|
||
- merged_skills 用与技能注入完全相同的六级可见性参数(generic 只 global),
|
||
技能作用域即工具作用域:机构装的技能包声明的 capability 工具只对该机构可见。
|
||
- capability 工具注册进本会话 registry(合成 ToolDefinition + 路由 handler
|
||
→ capability_tools.exec_capability_tool),native FC schema 自动带上。
|
||
- 策略表 pipeline_tool_policies 缺表/查询失败 → 按无策略继续(load_policies 内降级)。
|
||
"""
|
||
from pipeline_core.tool_sources import resolve_scoped_tools
|
||
|
||
# 与 _build_system_prompt 技能注入同款的可见性参数
|
||
pipeline_id = "" if self.generic else self.pipeline_id
|
||
role = "" if self.generic else (self.role or "")
|
||
project_id = "" if self.generic else self.project_id
|
||
org_id = "" if self.generic else (self.org_id or "")
|
||
user_id = "" if self.generic else (self.user_id or "")
|
||
|
||
merged = None
|
||
if self.config.skills.enabled and self._skill_loader:
|
||
try:
|
||
merged = self._skill_loader.get_merged(
|
||
pipeline_id=pipeline_id, role=role, project_id=project_id,
|
||
org_id=org_id, user_id=user_id)
|
||
except Exception as e:
|
||
logger.warning(f"get_merged for tool scopes failed: {e}")
|
||
|
||
from sqlor.dbpools import DBPools
|
||
db = DBPools()
|
||
async with db.sqlorContext("pipeline") as sor:
|
||
res = await resolve_scoped_tools(
|
||
sor, base_tools=self.config.tools, merged_skills=merged,
|
||
generic=self.generic, org_id=org_id, user_id=user_id,
|
||
pipeline_id=pipeline_id, role=role, project_id=project_id)
|
||
|
||
self._tool_scope_trace = res.trace
|
||
self._capability_tool_names = set(res.capability_tools or [])
|
||
self._allowed_tools = set(res.allowed)
|
||
|
||
# registry 重建:解析后的 ToolDefinition 集 + capability 工具合成注册
|
||
if self._tool_registry is not None:
|
||
from pipeline_core.tool_registry import ToolRegistry
|
||
from pipeline_core.agent_config import ToolDefinition
|
||
reg = ToolRegistry()
|
||
for t in res.tools:
|
||
reg.register(t)
|
||
# capability 工具:schema 从 capability_tools.TOOL_SCHEMAS 取,
|
||
# handler 路由到 exec_capability_tool(系统上下文自动注入,不暴露给 LLM)
|
||
for name in sorted(self._capability_tool_names):
|
||
if reg.get(name) is not None:
|
||
continue
|
||
try:
|
||
from .capability_tools import TOOL_SCHEMAS
|
||
schema = TOOL_SCHEMAS.get(name)
|
||
if not schema:
|
||
continue # 声明了但无实现 schema:跳过(不注册幻觉入口)
|
||
td = ToolDefinition(
|
||
name=name,
|
||
description=schema["description"],
|
||
parameters=dict(schema.get("params") or {}),
|
||
category="capability",
|
||
required=list(schema.get("required") or []),
|
||
)
|
||
reg.register(td)
|
||
except Exception as e:
|
||
logger.warning(f"capability tool {name} register failed: {e}")
|
||
self._tool_registry = reg
|
||
logger.info("tool scopes resolved: allowed=%d (capability=%d) trace=%s",
|
||
len(self._allowed_tools), len(self._capability_tool_names),
|
||
" | ".join(res.trace))
|
||
|
||
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,
|
||
purpose='utility',
|
||
session_id=self.session_id,
|
||
timeout=self._UTILITY_TIMEOUT,
|
||
)
|
||
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 ""
|
||
|
||
# 注入记忆(分域:通用 + 产线 + 项目 叠加)
|
||
# 产线隔离(2026-09-05):纯通用会话只读 global 通用记忆,绝不带
|
||
# 「产线+项目」叠加——否则通用助手会看到产线专属记忆。
|
||
if self.config.memory.enabled and self._memory_store:
|
||
# 多租户可见性版(2026-09-10):传 org_id/user_id → store 按
|
||
# visible_to 过滤(平台种子 + 本机构 org/pipeline/project + 本人 user),
|
||
# 替代旧的「global + pipeline 叠加」——旧版会把其他机构的
|
||
# org/pipeline/project 归属记忆也注入本会话(跨机构泄漏)。
|
||
if self.generic:
|
||
# 通用会话:只注入种子 + 本人 user 域 + 本机构 org 域
|
||
mem_block = await self._memory_store.build_prompt_block(
|
||
max_entries=15, org_id=self.org_id, user_id=self.user_id)
|
||
else:
|
||
mem_block = await self._memory_store.build_prompt_block(
|
||
max_entries=20, org_id=self.org_id, user_id=self.user_id,
|
||
pipeline_id=self.pipeline_id, project_id=self.project_id)
|
||
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:
|
||
# 产线隔离(2026-09-05):纯通用会话只检索 global 技能,
|
||
# 不挂产线/角色/项目/机构技能。
|
||
pipeline_id = "" if self.generic else (self.pipeline_id if self.config.skills.enable_pipeline else "")
|
||
role = "" if self.generic else (self.role if self.config.skills.enable_role else "")
|
||
project_id = "" if self.generic else (self.project_id if self.config.skills.enable_project else "")
|
||
org_id = "" if self.generic else (self.org_id if self.config.skills.enable_org else "")
|
||
user_id = "" if self.generic else (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)
|
||
|
||
# 敏感信息使用铁律(2026-09-17):仅在有真实用户身份时注入——
|
||
# 无人值守/内部 agent 不挂 secret 工具,也就不给凭据使用引导。
|
||
if self.user_id:
|
||
try:
|
||
from .secret_vault import SECRET_PROMPT_BLOCK
|
||
prompt += SECRET_PROMPT_BLOCK
|
||
except Exception as e:
|
||
logger.warning(f"inject secret prompt block failed: {e}")
|
||
|
||
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,
|
||
session_id=self.session_id,
|
||
timeout=self._MAIN_TIMEOUT,
|
||
)
|
||
except Exception as e:
|
||
# 原生视觉降级(2026-09-10):模型不支持多模态 content 数组时
|
||
# 上游会报错——剥离图片 parts 重试一次,并注入说明让 LLM 如实
|
||
# 告知用户「当前模型看不了图」(绝不静默丢图假装看过了)。
|
||
degraded = self._degrade_images_if_needed(e)
|
||
if degraded:
|
||
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,
|
||
session_id=self.session_id)
|
||
except Exception:
|
||
pass
|
||
logger.error(f"native function calling failed, fallback to text: {e}")
|
||
|
||
# 回退:纯文本调用(content 为原始文本)
|
||
from pipeline_service.llm_bridge import llm_call_msgs
|
||
try:
|
||
content = await llm_call_msgs(
|
||
self._msgs,
|
||
model=self.model_name,
|
||
temperature=self.config.temperature,
|
||
org_id=self.org_id,
|
||
session_id=self.session_id,
|
||
timeout=self._MAIN_TIMEOUT,
|
||
)
|
||
except Exception as e:
|
||
if self._degrade_images_if_needed(e):
|
||
content = await llm_call_msgs(
|
||
self._msgs, model=self.model_name,
|
||
temperature=self.config.temperature, org_id=self.org_id,
|
||
session_id=self.session_id, timeout=self._MAIN_TIMEOUT)
|
||
else:
|
||
raise
|
||
return {"content": content or "", "tool_calls": []}
|
||
|
||
def _degrade_images_if_needed(self, err: Exception) -> bool:
|
||
"""含图消息调用失败时剥离图片 parts 重试(一次性降级)。
|
||
|
||
只对「消息里确实有图」的失败生效;剥离后就地改写 self._msgs
|
||
(多模态 content 数组 → 纯文本 + 说明),返回是否执行了降级。
|
||
"""
|
||
if not self._has_images:
|
||
return False
|
||
self._has_images = False # 只降级一次
|
||
n_imgs = 0
|
||
for m in self._msgs:
|
||
c = m.get("content")
|
||
if isinstance(c, list):
|
||
texts = [pt.get("text", "") for pt in c if pt.get("type") == "text"]
|
||
n_imgs += sum(1 for pt in c if pt.get("type") == "image_url")
|
||
m["content"] = ("\n".join(t for t in texts if t) +
|
||
f"\n\n[系统说明:用户本次上传了 {n_imgs} 张图片,但当前模型"
|
||
f"({self.model_name or '默认模型'})调用图片失败"
|
||
f"({str(err)[:120]}),图片已剥离。请如实告知用户当前模型"
|
||
f"无法查看图片,可建议切换支持视觉的模型,或改用 "
|
||
f"invoke_model 的 i2t 图像理解能力。不要假装看过图片。]")
|
||
logger.warning(f"image degrade: stripped {n_imgs} images after LLM failure: {err}")
|
||
return n_imgs > 0
|
||
|
||
# ═══════════════════════════════════════════════════════
|
||
# 解析 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)
|
||
|
||
# 0. 五级作用域执行门禁(2026-09-10):schema 里不给看挡不住幻觉——
|
||
# LLM 仍可能喊出作用域外工具名(generic 喊 create_task、A 产线喊 B 产线
|
||
# 工具、他机构技能包的工具)。必须在分发前按解析出的允许集硬拦,
|
||
# 拒绝并回可行动提示(列出本会话真实可用工具),而不是「未知工具」让 LLM 瞎猜。
|
||
# _allowed_tools 为 None 时(解析失败降级)不拦截,回退旧行为。
|
||
if self._allowed_tools is not None and tool_name not in self._allowed_tools:
|
||
avail = sorted(self._allowed_tools)
|
||
shown = ", ".join(avail[:20]) + ("..." if len(avail) > 20 else "")
|
||
scope_desc = "通用会话" if self.generic else (
|
||
f"产线 {self.pipeline_id}" + (f"/角色 {self.role}" if self.role else ""))
|
||
return (f"FAIL: 工具 {tool_name} 不在本会话({scope_desc})的可用范围内,已拒绝执行。"
|
||
f"本会话可用工具: {shown}")
|
||
|
||
# 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]}"
|
||
|
||
# 1.5 capability 工具(技能 frontmatter 驱动,2026-09-10):
|
||
# 路由到 capability_tools.exec_capability_tool(按 TOOL_SCHEMAS 分发到
|
||
# 对应 capability 模块,系统上下文 project_id/iteration_id/who/agent_id
|
||
# 自动注入,不暴露给 LLM)。generic 会话不挂(作用域解析已排除)。
|
||
# ⚠️ 必须「名字被声明 且 TOOL_SCHEMAS 有 schema」双守卫(对齐 v1
|
||
# _exec_agent_tool 的 `if tool in TOOL_SCHEMAS`):技能 frontmatter 声明的
|
||
# capability 工具(task 技能的 claim_task/reset_task_retry 等)多数不在
|
||
# TOOL_SCHEMAS(那是概念工具 schema 表),真正实现是产线能力包 handler
|
||
# (第 2 步 _execute_ability_tool)。此前只查名字 → 整族任务状态机工具被
|
||
# 本步劫持,返回「FAIL: 未注册的能力工具」,能力包 handler 永远到不了
|
||
# (2026-09-14 pbls 项目实测:主 agent 反复调 reset_task_retry 全 FAIL)。
|
||
if tool_name in self._capability_tool_names:
|
||
from .capability_tools import TOOL_SCHEMAS, exec_capability_tool
|
||
if tool_name in TOOL_SCHEMAS:
|
||
return await exec_capability_tool(tool_name, params, self._build_ctx())
|
||
# 声明了但无概念工具 schema → 落到第 2 步产线能力包分发(真实 handler 所在)
|
||
|
||
# 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,
|
||
# 会话级隔离键(2026-09-05):能力 handler 的会话态缓存(如 extract 规格锚定)
|
||
# 必须按 session_id 隔离,防跨会话串数据
|
||
"session_id": self.session_id,
|
||
}
|
||
|
||
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 执行。"""
|
||
# 产线隔离(2026-09-05):纯通用会话不挂任何产线能力包,执行层直接拒绝。
|
||
if self.generic:
|
||
return None
|
||
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
|
||
|
||
# 产线隔离(2026-09-05):纯通用会话拒绝一切项目管理工具——此时
|
||
# tool_name 已经过 _PROJECT_TOOL_ALIASES 别名归一,别名变体同样被拦。
|
||
# schema 层(load_agent_config 剔除)+ 执行层(这里)双保险。
|
||
if self.generic and tool_name in _PROJECT_TOOL_NAMES:
|
||
return ("FAIL: 当前是通用助手会话(未挂产线插件),不提供项目管理能力,"
|
||
"无法查看或操作任何产线的项目。")
|
||
# 产线耦合工具拦截(2026-09-05 第 6 层):run_command 的 shell 无法圈禁
|
||
# 在工作目录内,通用会话一律拒绝。
|
||
if self.generic and tool_name in _GENERIC_DENIED_TOOLS:
|
||
return ("FAIL: 当前是通用助手会话,不提供命令执行能力。"
|
||
"如需处理文件,可直接上传文件,我会读取上传内容作答。")
|
||
|
||
# 通用内建工具映射(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,
|
||
"process": self._t_process,
|
||
# ── 通用工具集(Hermes CLI 能力子集)──
|
||
"read_file": self._t_read_file,
|
||
"patch_file": self._t_patch_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,
|
||
"memory": self._t_memory,
|
||
"manage_skill": self._t_manage_skill,
|
||
"todo": self._t_todo,
|
||
"delegate_subtask": self._t_delegate_subtask,
|
||
"subagent": self._t_subagent,
|
||
# ── 平台模型(2026-09-07:按任务自动选型 + 全能力调用)──
|
||
"list_platform_models": self._t_list_platform_models,
|
||
"invoke_model": self._t_invoke_model,
|
||
# ── 联网检索/网页抓取(2026-09-08,甲类只读能力,SSRF 防护在 web_tools)──
|
||
"web_search": self._t_web_search,
|
||
"fetch_url": self._t_fetch_url,
|
||
# ── 项目数据只读查询(2026-09-08,白名单+强制项目过滤在 db_query)──
|
||
"query_project_data": self._t_query_project_data,
|
||
# ── 用户敏感信息库(2026-09-17):签名适配 (sor,params,ctx) ← (sor,p,pid) ──
|
||
# ctx 用 _build_ctx()(含真实 self.user_id/self.org_id)——secret 归属必须
|
||
# 是真实身份,不能用作用域解析里被 generic 置空的那份。
|
||
**{n: (lambda _n: (lambda s, _p, _pid: self._t_secret_tool(s, _n, _p)))(n)
|
||
for n in _SECRET_TOOL_NAMES},
|
||
}
|
||
|
||
handler = handlers.get(tool_name)
|
||
if handler:
|
||
return await handler(sor, p, pid)
|
||
return None
|
||
|
||
async def _t_secret_tool(self, sor, tool_name: str, params: dict) -> str:
|
||
"""敏感信息工具统一入口(2026-09-17)。
|
||
|
||
身份强制来自会话真实上下文(self.user_id / self.org_id),忽略 LLM 传的任何
|
||
归属参数——防越权读写他人 secret。无 user_id(无人值守/内部 agent)一律拒绝。
|
||
"""
|
||
from . import secret_vault
|
||
if not self.user_id:
|
||
return "FAIL: 当前会话没有用户身份,不提供敏感信息能力(防无人值守场景泄露凭据)"
|
||
h = secret_vault.SECRET_HANDLERS.get(tool_name)
|
||
if not h:
|
||
return f"FAIL: 未知敏感信息工具 {tool_name}"
|
||
try:
|
||
_r = await h(sor, params or {}, {"user_id": self.user_id,
|
||
"org_id": self.org_id or "",
|
||
"project_id": self.project_id})
|
||
# 写操作必须显式 COMMIT(多 worker 架构:不提交则其他进程读不到刚存的凭据)。
|
||
# 与本文件其他写库工具(_persist_project 等 6 处)同款做法;只读工具多跑一次
|
||
# COMMIT 无害(sqlor 事务幂等),省去按工具名区分读写。
|
||
try:
|
||
await sor.sqlExe("COMMIT", {})
|
||
except Exception as _ce:
|
||
logger.warning(f"secret tool {tool_name} commit failed: {_ce}")
|
||
return _r
|
||
except Exception as e:
|
||
logger.error(f"secret tool {tool_name} error: {e}")
|
||
return f"ERROR: {str(e)[:300]}"
|
||
|
||
# ── 工具实现 ──
|
||
|
||
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)
|
||
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,
|
||
purpose='utility', session_id=self.session_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, _find_bwrap
|
||
|
||
# 通用会话(generic)strict 沙箱档(2026-09-08 乙类开放):
|
||
# bwrap 圈死——平台目录(/d/pipeline、/d/doit)完全不挂载(不可见),
|
||
# 可写根 = 用户自己的 _general/{uid} 目录。旧禁令的理由「shell 无法
|
||
# 圈禁在工作目录内、可枚举全工作空间」已被 strict 档根治(连读都不可见)。
|
||
# 无 bwrap 时 generic 一律拒绝(绝不降级为裸 shell——安全优先)。
|
||
if self.generic and not _find_bwrap():
|
||
return ("FAIL: 通用会话的命令执行需要 bwrap 沙箱(当前服务器不可用),已拒绝执行。"
|
||
"文件类操作请改用 read_file/write_file/list_files/search_files。")
|
||
|
||
# 敏感信息执行边界(2026-09-17):占位符 @@sec:NAME@@ → $PIPELINE_SEC_NAME,
|
||
# 真值经 env 注入子进程,**不进命令字符串**(命令可安全落库/进上下文)。
|
||
# 未知占位符原样保留(绝不替换成空串——空串会静默改变命令语义)。
|
||
# 必须在前台/后台分支**之前**处理,两条执行路径才都受保护。
|
||
secret_env = None
|
||
try:
|
||
from . import secret_vault
|
||
cmd, secret_env, unknown = await secret_vault.prepare_command(
|
||
sor, cmd, org_id=self.org_id or "", user_id=self.user_id or "")
|
||
if unknown:
|
||
return ("FAIL: 命令引用了不存在的敏感信息 " + ", ".join(unknown) +
|
||
"(已拒绝执行,防止空值改变命令语义)。用 list_secrets 核对名称。")
|
||
except Exception as e:
|
||
logger.warning(f"secret prepare_command failed (run without secrets): {e}")
|
||
|
||
# PII 执行边界(2026-09-17):@@pii:占位符 → $PIPELINE_PII_*,真值经 env 注入,
|
||
# 与凭据同构;缓存缺失时引用保留(命令取空,不静默改语义——与凭据不同:PII
|
||
# 缺失是过期常态,拒绝执行会误伤,取空+输出可见更安全)。
|
||
pii_env = None
|
||
if self.pii_session_key and "@@pii:" in cmd:
|
||
try:
|
||
from . import pii_guard
|
||
cmd, pii_env = await pii_guard.prepare_command(
|
||
cmd, self.pii_session_key)
|
||
except Exception as e:
|
||
logger.warning(f"pii prepare_command failed (run without pii env): {e}")
|
||
if pii_env:
|
||
secret_env = {**(secret_env or {}), **pii_env}
|
||
|
||
# 后台执行(2026-09-10,对齐 Hermes terminal background):
|
||
# 状态文件化到 workspace/.bg/,跨 worker 进程可 poll;
|
||
# 沙箱档位与前台完全一致(generic 强制 strict,无 bwrap 上面已拒)。
|
||
bg = str(p.get("background", "")).strip().lower()
|
||
if bg in ("true", "1", "yes"):
|
||
from . import bg_jobs
|
||
try:
|
||
job_id = await bg_jobs.start_bg_job(
|
||
cmd, self.workspace_dir, strict=self.generic,
|
||
secret_env=secret_env)
|
||
except bg_jobs.BgJobError as e:
|
||
return f"FAIL: {e}"
|
||
return (f"OK: 后台任务已启动 job_id={job_id}。"
|
||
f"用 process(action=poll|log|wait|kill, job_id={job_id}) 跟进输出与状态。")
|
||
|
||
try:
|
||
timeout = int(p.get("timeout") or 60)
|
||
except (TypeError, ValueError):
|
||
timeout = 60
|
||
timeout = max(5, min(timeout, 300))
|
||
|
||
r = await _run_shell(cmd, self.workspace_dir, timeout=timeout,
|
||
strict=self.generic, secret_env=secret_env)
|
||
out = f"rc={r['rc']}\n{r['stdout'][:2000]}"
|
||
if r.get('stderr'):
|
||
out += f"\nSTDERR: {r['stderr'][:500]}"
|
||
if self.generic and not r.get('sandbox'):
|
||
out += "\n(注意:本次未经过沙箱)"
|
||
# 出站净化(需求2的「执行返回当输入处理」):工具输出里若回显了
|
||
# 已入库凭据明文(报错信息常见),擦洗成占位符再回填模型上下文。
|
||
try:
|
||
from . import secret_vault
|
||
out, scrubbed = await secret_vault.sanitize_tool_output(
|
||
sor, out, org_id=self.org_id or "", user_id=self.user_id or "")
|
||
if scrubbed:
|
||
out += "\n(输出中出现的敏感信息已替换为占位符:" + ", ".join(scrubbed) + ")"
|
||
except Exception as e:
|
||
logger.warning(f"sanitize_tool_output failed: {e}")
|
||
return out
|
||
except Exception as e:
|
||
return f"ERROR: {str(e)[:300]}"
|
||
|
||
async def _t_process(self, sor, p, pid):
|
||
"""后台任务管理入口:先执行,再对输出做出站净化(2026-09-17)。
|
||
|
||
后台命令可能把凭据回显进 output.log(curl -v、报错信息常见),
|
||
读回模型上下文前擦洗成占位符——与前台 run_command 同一条防线。
|
||
"""
|
||
out = await self._t_process_raw(sor, p, pid)
|
||
try:
|
||
from . import secret_vault
|
||
out, scrubbed = await secret_vault.sanitize_tool_output(
|
||
sor, out, org_id=self.org_id or "", user_id=self.user_id or "")
|
||
if scrubbed:
|
||
out += "\n(输出中出现的敏感信息已替换为占位符:" + ", ".join(scrubbed) + ")"
|
||
except Exception as e:
|
||
logger.warning(f"sanitize process output failed: {e}")
|
||
return out
|
||
|
||
async def _t_process_raw(self, sor, p, pid):
|
||
"""后台任务管理(poll/log/wait/kill,配套 run_command background=true)。
|
||
|
||
隔离:只解析 self.workspace_dir 下的 .bg/——通用会话 workspace 是
|
||
_general/{uid}、项目会话是项目目录,跨用户/跨项目天然不可达;
|
||
job_id 白名单正则防路径穿越(bg_jobs 内校验)。
|
||
"""
|
||
from . import bg_jobs
|
||
action = (p.get("action") or "").strip().lower()
|
||
job_id = (p.get("job_id") or "").strip()
|
||
if action not in ("poll", "log", "wait", "kill"):
|
||
return "FAIL: action 须为 poll|log|wait|kill"
|
||
if not job_id:
|
||
return "FAIL: 需要 job_id(run_command background=true 的返回值)"
|
||
try:
|
||
offset = int(p.get("offset") or 0)
|
||
except (TypeError, ValueError):
|
||
offset = 0
|
||
try:
|
||
if action == "poll":
|
||
r = bg_jobs.poll_job(self.workspace_dir, job_id, offset)
|
||
m = r["meta"]
|
||
out = (f"job_id={job_id} status={m['status']} rc={m.get('rc')} "
|
||
f"sandbox={m.get('sandbox')} log_size={r['log_size']}")
|
||
if r["new_output"]:
|
||
out += f"\n--- 新增输出 ---\n{r['new_output'][-4000:]}"
|
||
return out
|
||
if action == "log":
|
||
r = bg_jobs.read_log(self.workspace_dir, job_id, offset)
|
||
m = r["meta"]
|
||
more = f"(还有后续,offset={offset + len(r['content'])} 续读)" if r["truncated"] else ""
|
||
return (f"job_id={job_id} status={m['status']} rc={m.get('rc')}\n"
|
||
f"--- 输出 ---\n{r['content']}{more}")
|
||
if action == "wait":
|
||
r = await bg_jobs.wait_job(self.workspace_dir, job_id)
|
||
m = r["meta"]
|
||
out = f"job_id={job_id} status={m['status']} rc={m.get('rc')}"
|
||
if m["status"] == "running":
|
||
out += "(等待超时,任务仍在跑,可继续 wait 或 poll)"
|
||
if r["new_output"]:
|
||
out += f"\n--- 输出(尾部) ---\n{r['new_output'][-4000:]}"
|
||
return out
|
||
if action == "kill":
|
||
r = bg_jobs.kill_job(self.workspace_dir, job_id)
|
||
return f"OK: {r['message']}(job_id={job_id})"
|
||
except bg_jobs.BgJobError as e:
|
||
return f"FAIL: {e}"
|
||
except Exception as e:
|
||
return f"ERROR: {str(e)[:300]}"
|
||
return "FAIL: 未知分支"
|
||
|
||
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}"
|
||
# 统一文件读取(file_read 共享模块,v1/v2 共用):分页续读 + docx/pdf 解析 + 显式截断告知
|
||
from .file_read import read_text_file, DEFAULT_LIMIT
|
||
try:
|
||
offset = int(p.get("offset") or 0)
|
||
except (ValueError, TypeError):
|
||
offset = 0
|
||
r = read_text_file(full, offset=offset, limit=DEFAULT_LIMIT)
|
||
if r['kind'] == 'error':
|
||
return f"FAIL: {r['message']} {path}"
|
||
if r['kind'] == 'binary':
|
||
return r['message']
|
||
return r['content']
|
||
|
||
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_platform_models(self, sor, p, pid):
|
||
"""列出平台可用模型(薄壳委托 platform_model_tools 唯一实现)。"""
|
||
from .platform_model_tools import tool_list_platform_models
|
||
return await tool_list_platform_models(p, self.org_id or "0")
|
||
|
||
async def _t_invoke_model(self, sor, p, pid):
|
||
"""调用平台模型完成生成类任务(薄壳委托 platform_model_tools 唯一实现)。"""
|
||
from .platform_model_tools import tool_invoke_model
|
||
return await tool_invoke_model(
|
||
p, self.org_id or "0", user_id=self.user_id or "",
|
||
project_id=self.project_id or "", session_id=self.session_id or "")
|
||
|
||
async def _t_web_search(self, sor, p, pid):
|
||
"""联网检索(薄壳委托 web_tools 唯一实现,SSRF 防护在其中)。"""
|
||
from .web_tools import tool_web_search
|
||
return await tool_web_search(p.get("query", ""), p.get("limit", 8))
|
||
|
||
async def _t_fetch_url(self, sor, p, pid):
|
||
"""抓取网页正文(超长落盘工作空间 webcache/,read_file 续读)。"""
|
||
from .web_tools import tool_fetch_url
|
||
return await tool_fetch_url(p.get("url", ""), workspace_dir=self.workspace_dir or "")
|
||
|
||
async def _t_query_project_data(self, sor, p, pid):
|
||
"""查询项目关联表数据(薄壳委托 db_query 唯一实现,白名单/过滤/审计在其中)。"""
|
||
if self.generic or not pid:
|
||
return "FAIL: 当前会话无项目上下文,无法查询项目数据。"
|
||
from .db_query import tool_query_project_data
|
||
return await tool_query_project_data(
|
||
sor, p.get("table", ""), pid,
|
||
where=p.get("where", ""), order_by=p.get("order_by", ""),
|
||
limit=p.get("limit", 100), who="agent.main_agent")
|
||
|
||
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
|
||
# 实时发布(2026-09-08 用户拍板):写提议者所属机构技能目录
|
||
# skills/orgs/{org_id}/{name}/,org scope 同名覆盖 global,
|
||
# 机构内立即生效、其他机构不受影响;org 0 有用户身份时降级
|
||
# users/{user_id}/(只影响本人),org 0 无人值守拒绝(防影响全平台)。
|
||
from .skill_live import publish_skill_live
|
||
ok, msg, info = await publish_skill_live(
|
||
name, description, content,
|
||
org_id=self.org_id or "", user_id=self.user_id or "",
|
||
who=self.user_id or "")
|
||
await sor.C("skill_proposals", {
|
||
"id": getID(),
|
||
"name": name,
|
||
"description": description,
|
||
"content": content,
|
||
"source": "agent",
|
||
# 实时发布成功=published(审计痕迹);被拒=保持 pending 人工审核链
|
||
"status": "published" if ok else "pending",
|
||
"feedback": msg if ok else "",
|
||
"org_id": self.org_id or "",
|
||
"pipeline_id": self.pipeline_id or "",
|
||
"created_by": self.user_id or "",
|
||
})
|
||
if ok:
|
||
return msg
|
||
# 实时发布被拒(org 0 无人值守等)→ 保持旧行为:提议待审核
|
||
return f"OK: 已提交技能提议 '{name}'({msg},转人工审核,暂不生效)"
|
||
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_patch_file(self, sor, p, pid):
|
||
"""定点替换(2026-09-10,对齐 Hermes patch 工具核心语义)。
|
||
|
||
- old_string 默认须在文件中唯一(0 次/多次都拒绝并给可行动提示),
|
||
replace_all=true 时替换全部出现;
|
||
- 路径经 _resolve_ws_path 越界防护(与 read/write_file 同一边界);
|
||
- 二进制/不可解码文件拒绝(文本文件专用);
|
||
- 原子写(.tmp + os.replace),防并发读到半截。
|
||
"""
|
||
path = (p.get("path") or "").strip()
|
||
old_string = p.get("old_string")
|
||
new_string = p.get("new_string")
|
||
if not path:
|
||
return "FAIL: 需要文件路径 path"
|
||
if not isinstance(old_string, str) or old_string == "":
|
||
return "FAIL: 需要 old_string(要替换的原文片段,删除时 new_string 传空字符串)"
|
||
if not isinstance(new_string, str):
|
||
return "FAIL: 需要 new_string(替换后的文本;删除片段传空字符串)"
|
||
if old_string == new_string:
|
||
return "FAIL: old_string 与 new_string 相同,无需修改"
|
||
full = self._resolve_ws_path(path)
|
||
if not full:
|
||
return f"FAIL: 路径越界 {path}"
|
||
if not os.path.isfile(full):
|
||
return f"FAIL: 文件不存在 {path}(新建文件用 write_file)"
|
||
replace_all = str(p.get("replace_all", "")).strip().lower() in ("true", "1", "yes")
|
||
try:
|
||
with open(full, "r", encoding="utf-8") as f:
|
||
text = f.read()
|
||
except UnicodeDecodeError:
|
||
return f"FAIL: {path} 不是 UTF-8 文本文件,patch_file 只支持文本"
|
||
except Exception as e:
|
||
return f"ERROR: 读取失败 {str(e)[:200]}"
|
||
n = text.count(old_string)
|
||
if n == 0:
|
||
return ("FAIL: old_string 在文件中未找到。先用 read_file 核对原文"
|
||
"(空白/缩进/换行必须完全一致)")
|
||
if n > 1 and not replace_all:
|
||
return (f"FAIL: old_string 在文件中出现 {n} 次(须唯一)。"
|
||
"请带更多上下文使其唯一,或 replace_all=true 替换全部")
|
||
new_text = text.replace(old_string, new_string) if replace_all \
|
||
else text.replace(old_string, new_string, 1)
|
||
try:
|
||
tmp = full + ".patch.tmp"
|
||
with open(tmp, "w", encoding="utf-8") as f:
|
||
f.write(new_text)
|
||
os.replace(tmp, full)
|
||
except Exception as e:
|
||
return f"ERROR: 写入失败 {str(e)[:200]}"
|
||
return (f"OK: {path} 已替换 {n if replace_all else 1} 处"
|
||
f"(文件 {len(text)}→{len(new_text)} 字符)")
|
||
|
||
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:
|
||
# 产线隔离(2026-09-05 第7层):通用会话只搜自己的历史
|
||
# (pipeline_id=''),与 _load_history 过滤对称;产线会话按
|
||
# iteration_id(项目)过滤不变。
|
||
if self.generic:
|
||
sql = ("SELECT role, content, created_at FROM pipeline_conversations "
|
||
"WHERE created_by=${u}$ AND (pipeline_id='' OR pipeline_id IS NULL) "
|
||
"AND content LIKE ${q}$ "
|
||
"ORDER BY created_at DESC LIMIT 10")
|
||
else:
|
||
sql = ("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")
|
||
recs = await sor.sqlExe(sql,
|
||
{"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_memory(self, sor, p, pid):
|
||
"""持久记忆工具(add/list/remove,2026-09-10 对齐 Hermes memory)。
|
||
|
||
多租户写入门禁(服务多机构多用户,全部代码层强制,不靠 LLM 自觉):
|
||
- org_id/user_id 一律用会话真实身份注入,忽略 LLM 传值(防伪造归属)
|
||
- scope 白名单:user/project/pipeline——global/org 是平台种子域,禁写
|
||
(global 域所有人可见,agent 写入=跨机构泄漏;org 域是平台运营配置)
|
||
- scope=project 必须有当前项目;scope=pipeline 的 scope_id 强制用
|
||
当前产线(防写进他产线域)
|
||
- remove 只能删「本机构 org_id + 本人或本机构名下」的条目
|
||
(store.remove 带 org/user 过滤),种子与他租户条目删不到
|
||
- 无 user_id(无人值守场景)拒绝写入——归属不明的记忆一律不收
|
||
"""
|
||
if not self._memory_store:
|
||
return "FAIL: 记忆系统未初始化"
|
||
from pipeline_core.memory_store import MemoryStore
|
||
action = (p.get("action") or "").strip().lower()
|
||
store = self._memory_store
|
||
org_id = self.org_id or ""
|
||
user_id = self.user_id or ""
|
||
|
||
if action == "add":
|
||
content = (p.get("content") or "").strip()
|
||
if not content:
|
||
return "FAIL: 需要 content(记忆内容)"
|
||
if not user_id:
|
||
return "FAIL: 无用户身份的会话不允许写记忆(归属不明)"
|
||
scope = (p.get("scope") or "user").strip().lower()
|
||
if scope not in MemoryStore.WRITABLE_SCOPES:
|
||
return ("FAIL: scope 只允许 user/project/pipeline"
|
||
"(global/org 是平台种子域,agent 禁写)")
|
||
category = (p.get("category") or "memory").strip().lower()
|
||
if category not in ("user", "memory"):
|
||
category = "memory"
|
||
scope_id = ""
|
||
if scope == "project":
|
||
if not self.project_id:
|
||
return "FAIL: 当前会话没有项目上下文,scope=project 不可用(改用 user 或 pipeline)"
|
||
scope_id = self.project_id
|
||
elif scope == "pipeline":
|
||
if self.generic or not self.pipeline_id:
|
||
return "FAIL: 通用会话没有产线上下文,scope=pipeline 不可用(改用 user)"
|
||
scope_id = self.pipeline_id
|
||
elif scope == "user":
|
||
scope_id = user_id
|
||
priority = MemoryStore.PRIORITY_HIGH if category == "user" else MemoryStore.PRIORITY_MEDIUM
|
||
# user_id 一律记创建者(所有 scope):user 域据此仅本人可见;
|
||
# project/pipeline 域可见性仍由 org_id 控制(机构内共享),
|
||
# 但删除时按 (org_id+user_id) 过滤=只能删本人写入的,
|
||
# 防同机构他人误删共享条目
|
||
await store.add(content=content, category=category, priority=priority,
|
||
scope=scope, scope_id=scope_id,
|
||
org_id=org_id, user_id=user_id)
|
||
scope_cn = {"user": "个人偏好", "project": "当前项目", "pipeline": "当前产线"}[scope]
|
||
return f"OK: 已记住({scope_cn}域,跨会话生效)"
|
||
|
||
if action == "list":
|
||
entries = await store.get_visible(org_id, user_id,
|
||
pipeline_id="" if self.generic else self.pipeline_id,
|
||
project_id=self.project_id)
|
||
if not entries:
|
||
return "(暂无可见记忆)"
|
||
lines = []
|
||
for e in entries[:30]:
|
||
lines.append(f"- [{e.scope}] key={e.key}: {e.content[:80]}")
|
||
if len(entries) > 30:
|
||
lines.append(f"(共 {len(entries)} 条,仅显示前 30)")
|
||
return "\n".join(lines)
|
||
|
||
if action == "remove":
|
||
key = (p.get("key") or "").strip()
|
||
if not key:
|
||
return "FAIL: remove 需要 key(先用 action=list 查)"
|
||
# 过滤 (org_id + 本人 user_id):只能删本人写入的条目。
|
||
# 种子条目 org_id=''、他机构条目 org_id≠本机构、同机构他人条目
|
||
# user_id≠本人,全部够不着;key 掺了租户维度防跨租户误删。
|
||
removed = 0
|
||
for cat in ("memory", "user"):
|
||
removed += await store.remove(key, cat, org_id=org_id, user_id=user_id)
|
||
if removed:
|
||
return f"OK: 已删除记忆 {key}"
|
||
return f"FAIL: 未找到可删除的记忆 {key}(只能删本机构/本人的条目)"
|
||
|
||
return "FAIL: action 须为 add|list|remove"
|
||
|
||
async def _t_manage_skill(self, sor, p, pid):
|
||
"""技能管理(create/patch/write_file/remove_file/delete,2026-09-10)。
|
||
|
||
隔离全部由 skill_live.manage_skill_live 把关:只能落本机构 orgs/{org}/
|
||
或 users/{uid}/ 目录;global 原版走 fork-on-write 继承副本;产线/角色/
|
||
项目层技能拒绝修改;delete 只删本租户副本。org_id/user_id 用会话真实
|
||
身份注入,忽略 LLM 传值。
|
||
"""
|
||
from .skill_live import manage_skill_live
|
||
action = (p.get("action") or "").strip().lower()
|
||
name = (p.get("name") or "").strip()
|
||
if not action:
|
||
return "FAIL: 需要 action(create/patch/write_file/remove_file/delete)"
|
||
ok, msg, info = await manage_skill_live(
|
||
action=action, name=name,
|
||
org_id=self.org_id or "", user_id=self.user_id or "",
|
||
who=self.user_id or "",
|
||
description=(p.get("description") or "").strip(),
|
||
content=p.get("content") or "",
|
||
old_string=p.get("old_string") or "",
|
||
new_string=p.get("new_string") or "",
|
||
file_path=(p.get("file_path") or "").strip(),
|
||
file_content=p.get("file_content") or "",
|
||
pipeline_id="" if self.generic else (self.pipeline_id or ""),
|
||
role=self.role or "",
|
||
project_id=self.project_id or "",
|
||
)
|
||
if ok:
|
||
# 审计:技能增改删留痕(append-only)
|
||
try:
|
||
from .audit import record_audit
|
||
await record_audit(
|
||
tenant_id=self.org_id or "0",
|
||
entity="skills", entity_id=name,
|
||
action=f"skill_{action}", who=self.user_id or "agent",
|
||
detail=str(info)[:500], sor=sor)
|
||
except Exception as e:
|
||
logger.warning(f"manage_skill audit failed: {e}")
|
||
return msg if ok else f"FAIL: {msg}"
|
||
|
||
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):
|
||
"""委派子 agent(2026-09-10 升级,对齐 Hermes delegate_task)。
|
||
|
||
background=false(默认):同步等待,行为同旧版(兼容存量用法)。
|
||
background=true:后台并行(同 workspace 上限 3 个),立即返回
|
||
subagent_id,用 subagent 工具 list/steer/stop/result 跟进。
|
||
|
||
隔离(两种模式一致):
|
||
- 子 executor session_isolation='none':不读父历史、不写
|
||
pipeline_conversations(子任务对话不污染父会话回放)
|
||
- 子 agent 不继承 session_id(多 tab 项目上下文不串)
|
||
- 深度限制:子 agent(_delegate_depth≥1)不能再委派,防递归爆炸
|
||
"""
|
||
goal = (p.get("goal", "") or "").strip()
|
||
context = (p.get("context", "") or "").strip()
|
||
if not goal:
|
||
return "FAIL: 需要子任务目标"
|
||
bg = str(p.get("background", "")).strip().lower() in ("true", "1", "yes")
|
||
|
||
if bg:
|
||
from . import subagents
|
||
try:
|
||
sid = await subagents.spawn_subagent(self, goal, context)
|
||
except subagents.SubagentError as e:
|
||
return f"FAIL: {e}"
|
||
return (f"OK: 子agent已后台启动 subagent_id={sid}。"
|
||
f"用 subagent(action=list) 查状态、steer 追加指示、"
|
||
f"stop 终止、result 取结果(完成前 result 是中间输出)。")
|
||
|
||
# 同步模式(深度限制同样生效)
|
||
from . import subagents
|
||
if self._delegate_depth >= subagents.MAX_DEPTH:
|
||
return "FAIL: 子agent不能再委派子任务(深度限制,防递归爆炸)"
|
||
try:
|
||
from dataclasses import replace as _dc_replace
|
||
child_config = _dc_replace(self.config, session_isolation="none")
|
||
sub = AgentExecutor(
|
||
config=child_config,
|
||
project_id=self.project_id,
|
||
user_id=self.user_id,
|
||
workspace_dir=self.workspace_dir,
|
||
model_name=self.model_name,
|
||
session_id="",
|
||
generic=self.generic,
|
||
default_pipeline_id=self.pipeline_id,
|
||
delegate_depth=self._delegate_depth + 1,
|
||
)
|
||
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 _t_subagent(self, sor, p, pid):
|
||
"""后台子agent管理(list/steer/stop/result)。隔离靠 workspace 目录。"""
|
||
from . import subagents
|
||
action = (p.get("action") or "").strip().lower()
|
||
sid = (p.get("subagent_id") or "").strip()
|
||
try:
|
||
if action == "list":
|
||
items = subagents.list_subagents(self.workspace_dir)
|
||
if not items:
|
||
return "(本会话还没有派生过后台子agent)"
|
||
lines = []
|
||
for it in items[:10]:
|
||
lines.append(
|
||
f"- {it['subagent_id']} [{it['status']}] {it['goal']}"
|
||
+ (f"(错误: {it['error']})" if it.get("error") else ""))
|
||
return "\n".join(lines)
|
||
if not sid:
|
||
return "FAIL: 需要 subagent_id(先用 action=list 查看)"
|
||
if action == "steer":
|
||
return subagents.steer_subagent(
|
||
self.workspace_dir, sid, (p.get("message") or "").strip())
|
||
if action == "stop":
|
||
return subagents.stop_subagent(self.workspace_dir, sid)
|
||
if action == "result":
|
||
r = subagents.get_result(self.workspace_dir, sid)
|
||
m = r["meta"]
|
||
return (f"subagent_id={sid} status={m['status']}\n"
|
||
f"--- 输出 ---\n{r['result']}")
|
||
except subagents.SubagentError as e:
|
||
return f"FAIL: {e}"
|
||
except Exception as e:
|
||
return f"ERROR: {str(e)[:300]}"
|
||
return "FAIL: action 须为 list|steer|stop|result"
|
||
|
||
# ═══════════════════════════════════════════════════════
|
||
# 上下文压缩
|
||
# ═══════════════════════════════════════════════════════
|
||
|
||
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 ""
|
||
if isinstance(content, list):
|
||
# 多模态消息(2026-09-10):文本部分按字符算,图片按固定 1000 token
|
||
# 估(base64 不进文本计数——图片实际 token 由视觉模型分块决定,
|
||
# 此处只需量级正确触发压缩阈值)
|
||
for pt in content:
|
||
if pt.get("type") == "text":
|
||
total += len(pt.get("text") or "") // 2 + 1
|
||
elif pt.get("type") == "image_url":
|
||
total += 1000
|
||
continue
|
||
# 简单估算:平均每 2 字符 1 token
|
||
total += len(content) // 2 + 1
|
||
return total
|
||
|
||
@staticmethod
|
||
def _content_as_text(content) -> str:
|
||
"""消息 content 归一为纯文本(多模态 list 取文本部分 + 图片占位)。
|
||
|
||
压缩/估算/日志统一走这里——list content 直接 len()/join 会 TypeError
|
||
(同 native FC content=None 的教训:所有碰 _msgs content 的地方都要防御)。
|
||
"""
|
||
if isinstance(content, list):
|
||
parts = []
|
||
for pt in content:
|
||
if pt.get("type") == "text":
|
||
parts.append(pt.get("text") or "")
|
||
elif pt.get("type") == "image_url":
|
||
parts.append("[图片]")
|
||
return "\n".join(parts)
|
||
return content or ""
|
||
|
||
async def _summarize(self, messages: list) -> str:
|
||
"""压缩消息为摘要"""
|
||
if len(messages) <= 2:
|
||
return "\n".join(self._content_as_text(m.get("content"))[:200] for m in messages)
|
||
|
||
text = "\n".join(
|
||
f"[{m['role']}]: {self._content_as_text(m.get('content'))[:500]}"
|
||
for m in messages
|
||
)
|
||
|
||
try:
|
||
from pipeline_service.llm_bridge import llm_call
|
||
|
||
summary = await llm_call(
|
||
f"请用3-5句话总结以下对话的关键信息:\n\n{text[:4000]}",
|
||
temperature=0.1,
|
||
model=self.model_name,
|
||
org_id=self.org_id,
|
||
purpose='utility',
|
||
session_id=self.session_id,
|
||
timeout=self._UTILITY_TIMEOUT,
|
||
)
|
||
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 独立会话)
|
||
# 注意(2026-09-01 修复):必须 ORDER BY created_at DESC LIMIT N 取「最新」N 条
|
||
# 再倒序回时间正序。历史用 ASC LIMIT 会取「最旧」N 条——会话消息超过
|
||
# history_limit 后,最近的提问(如 A/B/C 选项)被截掉,LLM 看不到刚问的
|
||
# 问题,按更旧的上下文答非所问(用户答 C,agent 复读 4 小时前的旧回复)。
|
||
if self.session_id:
|
||
# 产线隔离(2026-08-31 修复跨产线串扰):各产线页面默认 tab 共用
|
||
# session_id='default',历史若不按产线过滤,投标页会加载开发页的
|
||
# 对话(如"元景项目/新建demo项目"),LLM 跟着最后上下文答错产线。
|
||
# 2026-09-05 第7层泄漏修复:通用会话(存库时 pipeline_id='')只回放
|
||
# 自己的历史,否则会加载该用户各产线的最近对话——项目名称/ID
|
||
# 等产线信息随历史泄漏进通用助手。
|
||
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.generic:
|
||
sql += "AND (pipeline_id='' OR pipeline_id IS NULL) "
|
||
elif self.pipeline_id:
|
||
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},
|
||
)
|
||
elif self.generic:
|
||
# 2026-09-05 第7层泄漏修复:通用会话只回放自己的历史
|
||
# (存库时 generic 会话 pipeline_id=''),禁止按 user_id 全量
|
||
# 回放——否则用户各产线最近对话(含项目名称/ID)全部泄漏。
|
||
recs = await sor.sqlExe(
|
||
"SELECT role, content FROM pipeline_conversations "
|
||
"WHERE created_by=${uid}$ AND (pipeline_id='' OR pipeline_id IS NULL) "
|
||
"ORDER BY created_at DESC LIMIT ${lim}$",
|
||
{"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
|
||
|
||
# PII 落库掩码(2026-09-17):会话表是长期留存+session_search 可捞,占位符
|
||
# 虽非明文但缓存 24h 内可还原 → 落库一律换掩码形态(138****5678),
|
||
# 缓存过期后掩码仍是唯一可见形态(合规:留存数据不含可还原 PII)。
|
||
if self.pii_session_key and "@@pii:" in (user_input + reply):
|
||
try:
|
||
from . import pii_guard
|
||
if "@@pii:" in user_input:
|
||
user_input = await pii_guard.restore_outbound(
|
||
user_input, self.pii_session_key, masked=True)
|
||
if "@@pii:" in reply:
|
||
reply = await pii_guard.restore_outbound(
|
||
reply, self.pii_session_key, masked=True)
|
||
except Exception as e:
|
||
logger.warning(f"pii mask on save failed: {e}")
|
||
|
||
try:
|
||
from sqlor.dbpools import DBPools
|
||
from appPublic.uniqueID import getID
|
||
|
||
db = DBPools()
|
||
async with db.sqlorContext("pipeline") as sor:
|
||
# 产线隔离(2026-08-31):generic 会话不挂产线标签——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
|