diff --git a/.gitignore b/.gitignore index 7b2d37b..adda06e 100644 --- a/.gitignore +++ b/.gitignore @@ -22,10 +22,10 @@ backend/vendor/ *.test *.out -# Compiled binary -aiops -aiops-* -aiops.bak.* +# Compiled binary(前导 / 只匹配仓库根目录的二进制产物,避免误伤子目录同名文件夹如 frontend/src/components/aiops/) // Logan.Li +/aiops +/aiops-* +/aiops.bak.* # 前端构建产物(由 run.sh build 自动生成,非源码) backend/static/assets/ diff --git a/CLAUDE.md b/CLAUDE.md index 35b1907..fa24eab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,7 @@ 1. **资产管理** — 服务器管理、云资源(云账号/域名/账单)、K8s 集群 2. **监控告警** — Agent 采集指标、告警引擎(资源阈值/离线检测)、告警中心、链路追踪、监控大屏 -3. **自动化运维 / AIOps** — 流水线 CI/CD、定时任务/脚本库、远程执行(Go-native SSH)、资源申请审批、AI 智能运维(智能助手/告警分析/容量预测) +3. **自动化运维 / AIOps** — 流水线 CI/CD、定时任务/脚本库、远程执行(Go-native SSH)、资源申请审批、AI 智能运维(智能助手/告警分析/容量预测/Agent CLI) ### 核心特性 @@ -23,6 +23,7 @@ - **凭证系统 v2** — 7 种统一凭证类型(SSH/Docker/TLS/AKSK/DB/API/Generic),加密存储 - **CI/CD 流水线** — GitLab CI 风格可视化,阶段色块进度条,实时日志,文件浏览器 - **Kubectl 终端** — WebSocket + PTY 交互式 K8s 终端,懒加载 kubectl 二进制 +- **Agent CLI** — Web 版 AI 运维 Agent(参考 opsxcli 移植),关联主机后自然语言驱动 SSH 排障/部署,ReAct 循环解决错误,WebSocket 实时回传,Evolver 自进化学习 ## 技术栈 @@ -174,6 +175,7 @@ aiops/ │ │ ├── database/ # DB 连接 + AutoMigrate │ │ ├── kubernetesclient/ # 轻量 K8s API 客户端 │ │ ├── remoteexec/ # Go-native SSH 远程执行 +│ │ ├── agentcli/ # AI 运维 Agent 引擎(ReAct + 工具 + 安全 + Evolver,见 docs/agent/) │ │ └── toolinstaller/ # 工具懒加载 + 镜像回退 │ └── Dockerfile # 3 阶段构建 (Backend+Agent→Alpine Runtime) │ @@ -187,7 +189,7 @@ aiops/ │ │ ├── monitor/ # 监控告警 │ │ ├── automation/ # 定时任务/脚本库/远程执行 │ │ ├── approval/ # 资源申请审批 -│ │ ├── aiops/ # AI 智能运维(助手/告警分析/容量预测) +│ │ ├── aiops/ # AI 智能运维(助手/告警分析/容量预测/Agent CLI/大模型设置) │ │ ├── system/ # 系统管理 │ │ └── layout/ # Sidebar/Header │ ├── src/services/ @@ -339,3 +341,4 @@ Git 用户: attacker / attacker@gitee.com - **K8s 客户端** — 轻量自研(net/http + crypto/tls),不引入 50MB+ 的 client-go - **BuildKit cache mount** — Docker 构建从 13min 降至 3sec(二次构建) - **健康检查用 `/version`** — K8s 中 `/health` 可能依赖 DB,`/version` 始终 200 +- **Agent CLI 引擎** — 移植自 opsxcli 的 AI Agent 能力到 `pkg/agentcli/`(纯引擎库,零业务依赖,剥离 CLI/TUI 外壳):ReAct 16 轮循环 + 自适应检查点 + 循环检测;工具精简为 `ssh_execute`(复用 `pkg/remoteexec`)+ `analyze_output`;Evolver 四层记忆异步学习并注入下次 Prompt;会话用 GORM 表(`agent_cli_sessions/messages`)多用户隔离;高危命令经 WebSocket `confirm` 事件 + 前端 `useConfirm()` 确认;设计文档见 `docs/agent/` diff --git a/backend/controller/agent_cli.go b/backend/controller/agent_cli.go new file mode 100644 index 0000000..e85f5bd --- /dev/null +++ b/backend/controller/agent_cli.go @@ -0,0 +1,328 @@ +// agent_cli.go - Agent CLI WebSocket 控制器 // Logan.Li +// 驱动 pkg/agentcli 引擎:关联主机(model.Server 凭据)→ 构建引擎 → ReAct 循环, +// 通过 WebSocket 实时推送思考/工具调用/输出/结果,高危命令经前端确认,过程持久化到 DB。 +package controller + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "sync" + "time" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + "gorm.io/gorm" + + "ezops/middleware" + "ezops/model" + "ezops/pkg/agentcli" + "ezops/pkg/agentcli/core" + "ezops/pkg/agentcli/llm" + "ezops/pkg/agentcli/safety" + "ezops/pkg/agentcli/tools" + "ezops/pkg/remoteexec" +) + +// AgentCLIHandler Agent CLI 控制器 // Logan.Li +type AgentCLIHandler struct { + db *gorm.DB +} + +// NewAgentCLIHandler 创建控制器 // Logan.Li +func NewAgentCLIHandler(db *gorm.DB) *AgentCLIHandler { return &AgentCLIHandler{db: db} } + +// wsEvent WebSocket 事件信封(后端→前端) // Logan.Li +type wsEvent struct { + Type string `json:"type"` + Round int `json:"round,omitempty"` + SessionID uint `json:"session_id,omitempty"` + Thought string `json:"thought,omitempty"` + ToolName string `json:"tool_name,omitempty"` + ToolArgs map[string]interface{} `json:"tool_args,omitempty"` + Risk string `json:"risk,omitempty"` + Output string `json:"output,omitempty"` + Success bool `json:"success,omitempty"` + Duration string `json:"duration,omitempty"` + Tokens int `json:"tokens,omitempty"` + Answer string `json:"answer,omitempty"` + Message string `json:"message,omitempty"` + ConfirmID string `json:"confirm_id,omitempty"` + Command string `json:"command,omitempty"` +} + +// wsControl 前端→后端控制消息 // Logan.Li +type wsControl struct { + Type string `json:"type"` // start/confirm/deny/abort + Query string `json:"query,omitempty"` // start 时任务描述 + ConfirmID string `json:"confirm_id,omitempty"` // confirm/deny 时 +} + +// agentConn 单个 WS 连接的运行态 // Logan.Li +type agentConn struct { + conn *websocket.Conn + db *gorm.DB + userID uint + server model.Server + session *model.AgentCLISession + cancel context.CancelFunc + writeMu sync.Mutex + confirms map[string]chan bool + cfMu sync.Mutex +} + +// Run WebSocket 入口:GET /ws/agent-cli?id=&token= // Logan.Li +func (h *AgentCLIHandler) Run(c *gin.Context) { + serverID := c.Query("id") + if serverID == "" { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "缺少主机 id"}) + return + } + var server model.Server + if err := h.db.First(&server, serverID).Error; err != nil { + c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "主机不存在"}) + return + } + sshCfg, err := buildSSHConfig(h.db, server) + if err != nil { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()}) + return + } + // LLM 配置 + var llmCfg model.LLMConfig + if err := h.db.First(&llmCfg).Error; err != nil || !llmCfg.Enabled { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "LLM 服务未配置或未启用,请先在「大模型设置」中配置"}) + return + } + + conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + return + } + defer conn.Close() + + ac := &agentConn{ + conn: conn, db: h.db, + userID: middleware.GetUserID(c), server: server, + confirms: make(map[string]chan bool), + } + // 推送主机上下文 + ac.write(wsEvent{Type: "session", SessionID: 0, + Message: fmt.Sprintf("已连接主机 %s(%s),LLM: %s。请描述要执行的任务。", server.Hostname, server.IP, llmCfg.Model)}) + + // 读循环:处理前端控制消息 + for { + _, data, err := conn.ReadMessage() + if err != nil { + ac.abort() + return + } + var ctrl wsControl + if json.Unmarshal(data, &ctrl) != nil { + continue + } + switch ctrl.Type { + case "start": + if ctrl.Query == "" { + ac.write(wsEvent{Type: "error", Message: "任务描述不能为空"}) + continue + } + ac.startTask(sshCfg, llmCfg, ctrl.Query) + case "confirm": + ac.resolveConfirm(ctrl.ConfirmID, true) + case "deny": + ac.resolveConfirm(ctrl.ConfirmID, false) + case "abort": + ac.abort() + } + } +} + +// startTask 启动一次任务(新会话 + 新引擎) // Logan.Li +func (ac *agentConn) startTask(sshCfg remoteexec.SSHConfig, llmCfg model.LLMConfig, query string) { + ac.abort() // 取消上一次任务 + + // 创建 DB 会话 + title := query + if r := []rune(title); len(r) > 60 { + title = string(r[:60]) + "..." + } + sess := &model.AgentCLISession{ + UserID: ac.userID, ServerID: ac.server.ID, + ServerName: ac.server.Hostname, ServerHost: ac.server.IP, + Title: title, Status: "running", + Provider: llmCfg.Provider, Model: llmCfg.Model, + } + if err := ac.db.Create(sess).Error; err != nil { + ac.write(wsEvent{Type: "error", Message: "创建会话失败: " + err.Error()}) + return + } + ac.session = sess + + // 存用户消息 + ac.persistMsg(sess.ID, 0, "user", "user", query, "", "", "", "", false, "", 0) + + // 构建引擎 + engine, err := agentcli.NewEngine(agentcli.EngineConfig{ + LLM: llm.Config{ + Endpoint: resolveEndpoint(llmCfg.Provider, llmCfg.Endpoint), + APIKey: llmCfg.APIKey, Model: llmCfg.Model, + Temperature: llmCfg.Temperature, MaxTokens: llmCfg.MaxTokens, + }, + SSH: sshCfg, + SafetyMode: safety.ModeBalanced, + }) + if err != nil { + ac.finish(sess, "error", "引擎初始化失败: "+err.Error(), 0, time.Duration(0)) + return + } + engine.SetCallback(ac.onEvent) + engine.SetConfirmFn(ac.confirmFn) + + ctx, cancel := context.WithCancel(context.Background()) + ac.cancel = cancel + serverInfo := fmt.Sprintf("主机名: %s | IP: %s:%d | 用户: %s | OS: %s", ac.server.Hostname, ac.server.IP, ac.server.Port, ac.server.Username, ac.server.OS) + + ac.write(wsEvent{Type: "session", SessionID: sess.ID, Message: "任务已开始"}) + + go func() { + defer engine.Close() + start := time.Now() + _, err := engine.Run(ctx, query, serverInfo) + dur := time.Since(start) + if ctx.Err() != nil { + ac.finish(sess, "aborted", "任务已中断", ac.lastRound(), dur) + } else if err != nil { + ac.finish(sess, "error", err.Error(), ac.lastRound(), dur) + } else { + ac.finish(sess, "done", "", ac.lastRound(), dur) + } + }() +} + +// onEvent 引擎事件回调:推 WS + 持久化 // Logan.Li +func (ac *agentConn) onEvent(e core.Event) { + ev := wsEvent{ + Type: string(e.Type), Round: e.Round, Thought: e.Thought, + ToolName: e.ToolName, ToolArgs: e.ToolArgs, Risk: e.Risk, + Output: e.Output, Success: e.Success, Duration: e.Duration, + Tokens: e.Tokens, Answer: e.Answer, Message: e.Message, + } + if ac.session != nil { + ev.SessionID = ac.session.ID + } + ac.write(ev) + + // 持久化(progress 太频繁不入库) + if ac.session == nil { + return + } + sid := ac.session.ID + switch e.Type { + case core.EventThought: + ac.persistMsg(sid, e.Round, "thought", "assistant", e.Thought, "", "", "", "", false, "", 0) + case core.EventToolCall: + argsJSON, _ := json.Marshal(e.ToolArgs) + ac.persistMsg(sid, e.Round, "tool_call", "tool", "", e.ToolName, string(argsJSON), "", e.Risk, false, "", 0) + case core.EventToolResult: + ac.persistMsg(sid, e.Round, "tool_result", "tool", e.Output, e.ToolName, "", "", "", e.Success, e.Duration, 0) + case core.EventDone: + ac.persistMsg(sid, e.Round, "done", "assistant", e.Answer, "", "", "", "", true, "", e.Tokens) + case core.EventError: + ac.persistMsg(sid, e.Round, "error", "assistant", e.Message, "", "", "", "", false, "", 0) + } +} + +// confirmFn 高危命令确认回调:推 confirm 事件并阻塞等前端回传 // Logan.Li +func (ac *agentConn) confirmFn(toolName string, args map[string]interface{}, risk tools.RiskLevel) (bool, error) { + confirmID := fmt.Sprintf("cf-%d", time.Now().UnixNano()) + cmd, _ := args["command"].(string) + ac.write(wsEvent{Type: "confirm", ConfirmID: confirmID, ToolName: toolName, Command: cmd, Risk: risk.Display(), ToolArgs: args}) + + ch := make(chan bool, 1) + ac.cfMu.Lock() + ac.confirms[confirmID] = ch + ac.cfMu.Unlock() + + select { + case approved := <-ch: + return approved, nil + case <-time.After(5 * time.Minute): + ac.delConfirm(confirmID) + return false, fmt.Errorf("确认超时") + } +} + +// resolveConfirm 前端回传确认结果 // Logan.Li +func (ac *agentConn) resolveConfirm(confirmID string, approved bool) { + ac.cfMu.Lock() + ch, ok := ac.confirms[confirmID] + if ok { + delete(ac.confirms, confirmID) + } + ac.cfMu.Unlock() + if ok { + ch <- approved + } +} + +func (ac *agentConn) delConfirm(id string) { + ac.cfMu.Lock() + delete(ac.confirms, id) + ac.cfMu.Unlock() +} + +// abort 中断当前任务 // Logan.Li +func (ac *agentConn) abort() { + if ac.cancel != nil { + ac.cancel() + ac.cancel = nil + } +} + +// lastRound 估算已完成轮次(从 DB 取该会话最大 round) // Logan.Li +func (ac *agentConn) lastRound() int { + if ac.session == nil { + return 0 + } + var maxRound int + ac.db.Model(&model.AgentCLIMessage{}).Where("session_id = ?", ac.session.ID). + Select("COALESCE(MAX(round),0)").Scan(&maxRound) + return maxRound +} + +// finish 结束会话并更新 DB // Logan.Li +func (ac *agentConn) finish(sess *model.AgentCLISession, status, errMsg string, rounds int, dur time.Duration) { + if status == "done" || status == "aborted" || status == "error" { + ac.write(wsEvent{Type: status, SessionID: sess.ID, Message: errMsg, Round: rounds}) + } + updates := map[string]interface{}{ + "status": status, "rounds": rounds, "duration": dur.Milliseconds(), + } + if errMsg != "" { + updates["error"] = errMsg + } + ac.db.Model(sess).Updates(updates) +} + +// write 线程安全写 WS 消息 // Logan.Li +func (ac *agentConn) write(ev wsEvent) { + ac.writeMu.Lock() + defer ac.writeMu.Unlock() + b, _ := json.Marshal(ev) + _ = ac.conn.WriteMessage(websocket.TextMessage, b) +} + +// persistMsg 持久化一条消息 // Logan.Li +func (ac *agentConn) persistMsg(sid uint, round int, typ, role, content, toolName, args, output, risk string, success bool, dur string, tokens int) { + msg := &model.AgentCLIMessage{ + SessionID: sid, Round: round, Type: typ, Role: role, Content: content, + ToolName: toolName, ToolArgs: args, ToolOutput: output, Risk: risk, + Success: success, Duration: dur, Tokens: tokens, + } + if err := ac.db.Create(msg).Error; err != nil { + // 持久化失败不阻断主流程 + _ = err + } +} diff --git a/backend/controller/agent_cli_api.go b/backend/controller/agent_cli_api.go new file mode 100644 index 0000000..50dfde4 --- /dev/null +++ b/backend/controller/agent_cli_api.go @@ -0,0 +1,70 @@ +// agent_cli_api.go - Agent CLI 会话历史 REST 接口 // Logan.Li +// 供前端展示历史任务列表与回放某次任务的完整推理过程。 +package controller + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + + "ezops/middleware" + "ezops/model" +) + +// ListSessions 会话列表(按当前用户隔离) // Logan.Li +// GET /api/v1/aiops/agent-cli/sessions?server_id=&page=&page_size= +func (h *AgentCLIHandler) ListSessions(c *gin.Context) { + uid := middleware.GetUserID(c) + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + if page < 1 { + page = 1 + } + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + if pageSize <= 0 || pageSize > 100 { + pageSize = 20 + } + + q := h.db.Model(&model.AgentCLISession{}).Where("user_id = ?", uid) + if sid := c.Query("server_id"); sid != "" { + q = q.Where("server_id = ?", sid) + } + + var total int64 + q.Count(&total) + var sessions []model.AgentCLISession + if err := q.Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&sessions).Error; err != nil { + c.JSON(http.StatusOK, model.Response{Code: 500, Message: "查询失败: " + err.Error()}) + return + } + c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{"list": sessions, "total": total}}) +} + +// GetSession 会话详情(含完整推理消息,供回放) // Logan.Li +// GET /api/v1/aiops/agent-cli/sessions/:id +func (h *AgentCLIHandler) GetSession(c *gin.Context) { + id := c.Param("id") + var sess model.AgentCLISession + if err := h.db.First(&sess, id).Error; err != nil { + c.JSON(http.StatusOK, model.Response{Code: 404, Message: "会话不存在"}) + return + } + var msgs []model.AgentCLIMessage + h.db.Where("session_id = ?", id).Order("id ASC").Find(&msgs) + c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{"session": sess, "messages": msgs}}) +} + +// DeleteSession 删除会话及其消息 // Logan.Li +// DELETE /api/v1/aiops/agent-cli/sessions/:id +func (h *AgentCLIHandler) DeleteSession(c *gin.Context) { + id := c.Param("id") + if err := h.db.Where("session_id = ?", id).Delete(&model.AgentCLIMessage{}).Error; err != nil { + c.JSON(http.StatusOK, model.Response{Code: 500, Message: "删除消息失败: " + err.Error()}) + return + } + if err := h.db.Delete(&model.AgentCLISession{}, id).Error; err != nil { + c.JSON(http.StatusOK, model.Response{Code: 500, Message: "删除会话失败: " + err.Error()}) + return + } + c.JSON(http.StatusOK, model.Response{Code: 200, Message: "已删除"}) +} diff --git a/backend/model/agent_cli.go b/backend/model/agent_cli.go new file mode 100644 index 0000000..f7ca633 --- /dev/null +++ b/backend/model/agent_cli.go @@ -0,0 +1,49 @@ +// agent_cli.go - Agent CLI 会话与消息持久化模型 // Logan.Li +// 记录 AI 运维 Agent 的每次任务(关联主机 + 用户)及其 ReAct 推理过程 +// (思考/工具调用/输出/结果),供前端历史回放与审计。 +package model + +import "time" + +// AgentCLISession Agent CLI 任务会话 // Logan.Li +type AgentCLISession struct { + ID uint `gorm:"primarykey" json:"id"` + UserID uint `gorm:"index" json:"user_id"` // 发起用户 + ServerID uint `gorm:"index" json:"server_id"` // 目标主机 + ServerName string `gorm:"size:128" json:"server_name"` // 主机名快照 + ServerHost string `gorm:"size:64" json:"server_host"` // 主机 IP 快照 + Title string `gorm:"size:255" json:"title"` // 任务标题(query 截断) + Status string `gorm:"size:32;default:running" json:"status"` // running/done/aborted/error + Provider string `gorm:"size:50" json:"provider"` // LLM 提供商 + Model string `gorm:"size:100" json:"model"` // 模型名 + Rounds int `json:"rounds"` // ReAct 轮次 + TotalTokens int `json:"total_tokens"` // token 消耗 + Duration int64 `json:"duration"` // 耗时(毫秒) + Error string `gorm:"type:text" json:"error,omitempty"` // 错误信息 + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// TableName 表名 // Logan.Li +func (AgentCLISession) TableName() string { return "agent_cli_sessions" } + +// AgentCLIMessage Agent CLI 任务消息(ReAct 推理过程的每一步) // Logan.Li +type AgentCLIMessage struct { + ID uint `gorm:"primarykey" json:"id"` + SessionID uint `gorm:"index;not null" json:"session_id"` + Round int `json:"round"` // ReAct 轮次 + Type string `gorm:"size:32" json:"type"` // thought/tool_call/tool_result/done/error/session + Role string `gorm:"size:32" json:"role"` // user/assistant/tool + Content string `gorm:"type:text" json:"content"` // 思考文本/最终答案 + ToolName string `gorm:"size:64" json:"tool_name"` // 工具名 + ToolArgs string `gorm:"type:text" json:"tool_args"` // 工具参数 JSON + ToolOutput string `gorm:"type:text" json:"tool_output"` // 工具输出 + Risk string `gorm:"size:32" json:"risk"` // 风险等级 + Success bool `json:"success"` + Duration string `gorm:"size:32" json:"duration"` // 工具耗时 + Tokens int `json:"tokens"` + CreatedAt time.Time `gorm:"index" json:"created_at"` +} + +// TableName 表名 // Logan.Li +func (AgentCLIMessage) TableName() string { return "agent_cli_messages" } diff --git a/backend/pkg/agentcli/agentcli.go b/backend/pkg/agentcli/agentcli.go new file mode 100644 index 0000000..6460164 --- /dev/null +++ b/backend/pkg/agentcli/agentcli.go @@ -0,0 +1,109 @@ +// Package agentcli 是 Agent CLI 引擎的顶层入口。 // Logan.Li +// 组装 llm 客户端 + 工具注册中心(含 ssh_execute)+ 安全控制器 + Evolver + ReAct 核心, +// 对外暴露 Engine,由 controller 通过依赖注入(EventCallback / ConfirmFn / SSH 凭据)驱动。 +// +// 设计原则(参考 opsxcli,适配为 aiops 后端库): +// - 零业务依赖:不 import model/controller,所有持久化与 WS 由 controller 层负责。 +// - 复用 aiops 既有能力:LLM 走 OpenAI 兼容(model.LLMConfig),SSH 走 pkg/remoteexec。 +// - 安全:危险命令黑名单硬拒,高危命令经 ConfirmFn 由前端确认。 +// - 自进化:Evolver 异步学习经验并注入下次 Prompt,失败优雅降级。 +package agentcli + +import ( + "context" + + "ezops/pkg/agentcli/core" + "ezops/pkg/agentcli/evolver" + "ezops/pkg/agentcli/llm" + "ezops/pkg/agentcli/safety" + "ezops/pkg/agentcli/tools" + "ezops/pkg/remoteexec" +) + +// EngineConfig 引擎配置(由 controller 从 model.LLMConfig + model.Server 构建后注入) // Logan.Li +type EngineConfig struct { + LLM llm.Config // LLM 客户端配置(endpoint 已解析) + SSH remoteexec.SSHConfig // 目标主机 SSH 配置(含凭据) + SafetyMode safety.Mode // 安全模式(默认 balanced) + Core *core.Config // ReAct 配置(nil 用默认) + MemoryDir string // Evolver 记忆目录(空则 data/agentcli) +} + +// Engine Agent CLI 引擎实例(每个 WebSocket 连接 / 任务一个) // Logan.Li +type Engine struct { + agent *core.Agent + safety *safety.Controller + tools *tools.Registry + evolver *evolver.EvolverEngine +} + +// NewEngine 组装引擎并注册默认工具集 // Logan.Li +func NewEngine(cfg EngineConfig) (*Engine, error) { + coreCfg := cfg.Core + if coreCfg == nil { + coreCfg = core.DefaultConfig() + } + if cfg.MemoryDir != "" { + coreCfg.MemoryDir = cfg.MemoryDir + } + mode := cfg.SafetyMode + if mode == "" { + mode = safety.ModeBalanced + } + + // LLM 客户端 + client := llm.NewClient(cfg.LLM) + + // 工具注册中心:ssh_execute(注入目标主机凭据)+ analyze_output + registry := tools.NewRegistry() + registry.Register(tools.NewSSHExecuteTool(cfg.SSH, coreCfg.ToolTimeout)) + registry.Register(tools.NewAnalyzeOutputTool()) + + // 安全控制器 + sc := safety.NewController(mode) + + // Evolver(LLM 可用时启用学习;失败降级) + ev, err := evolver.NewEvolverEngine(coreCfg.MemoryDir, client) + if err != nil { + ev = nil // 降级:无自学习,不影响主流程 + } + + agent := core.NewAgent(client, registry, coreCfg, sc, ev) + return &Engine{agent: agent, safety: sc, tools: registry, evolver: ev}, nil +} + +// SetCallback 注入事件回调(驱动前端渲染 + DB 持久化) // Logan.Li +func (e *Engine) SetCallback(cb core.EventCallback) { e.agent.SetCallback(cb) } + +// SetConfirmFn 注入高危确认回调(controller 推 WS confirm 事件并阻塞等前端回传) // Logan.Li +func (e *Engine) SetConfirmFn(fn safety.ConfirmFn) { e.safety.SetConfirmFn(fn) } + +// SetAutoApprove 测试用:自动批准(生产禁用) // Logan.Li +func (e *Engine) SetAutoApprove(b bool) { e.safety.SetAutoApprove(b) } + +// Run 执行任务(ReAct 循环),serverInfo 为目标主机摘要(注入 system prompt) // Logan.Li +func (e *Engine) Run(ctx context.Context, query, serverInfo string) (string, error) { + res, err := e.agent.Run(ctx, query, serverInfo) + if err != nil { + return "", err + } + if res != nil { + return res.Output, nil + } + return "", nil +} + +// EvolveStats 进化统计(供审计展示) // Logan.Li +func (e *Engine) EvolveStats() map[string]interface{} { + if e.evolver == nil { + return map[string]interface{}{"enabled": false} + } + return e.evolver.GetStats() +} + +// Close 释放资源 // Logan.Li +func (e *Engine) Close() { + if e.agent != nil { + e.agent.Close() + } +} diff --git a/backend/pkg/agentcli/core/agent.go b/backend/pkg/agentcli/core/agent.go new file mode 100644 index 0000000..f056424 --- /dev/null +++ b/backend/pkg/agentcli/core/agent.go @@ -0,0 +1,436 @@ +// Package core 实现 Agent CLI 的 ReAct 核心引擎。 // Logan.Li +// 设计参考 opsxcli internal/agent/core,剥离 TUI(Bubble Tea),改用 EventCallback 推送事件流 +// (由 controller 序列化为 WebSocket 消息推给前端 + 持久化到 DB)。 +// 核心算法保留:16 轮 ReAct 主循环、自适应检查点、循环检测(同参>3 次拒绝)、连续失败追踪、Evolver 集成。 +package core + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "sync" + "time" + + "ezops/pkg/agentcli/evolver" + "ezops/pkg/agentcli/llm" + "ezops/pkg/agentcli/prompt" + "ezops/pkg/agentcli/safety" + "ezops/pkg/agentcli/tools" +) + +// EventType 事件类型(驱动前端渲染与 DB 持久化) // Logan.Li +type EventType string + +const ( + EventThought EventType = "thought" // AI 思考/推理文本 + EventToolCall EventType = "tool_call" // 即将执行的工具(名/参数/风险) + EventToolResult EventType = "tool_result" // 工具执行结果(输出/成败/耗时) + EventProgress EventType = "progress" // 轮次进度 + token + EventDone EventType = "done" // 任务完成(最终答案) + EventError EventType = "error" // 错误(含达到最大轮次) +) + +// Event 引擎产生的事件(controller 序列化后通过 WebSocket 推送) // Logan.Li +type Event struct { + Type EventType `json:"type"` + Round int `json:"round,omitempty"` + Thought string `json:"thought,omitempty"` + ToolName string `json:"tool_name,omitempty"` + ToolArgs map[string]interface{} `json:"tool_args,omitempty"` + Risk string `json:"risk,omitempty"` + Output string `json:"output,omitempty"` + Success bool `json:"success,omitempty"` + Duration string `json:"duration,omitempty"` + Tokens int `json:"tokens,omitempty"` + Answer string `json:"answer,omitempty"` + Message string `json:"message,omitempty"` +} + +// EventCallback 事件回调(controller 注入) // Logan.Li +type EventCallback func(Event) + +// Config Agent 运行配置 // Logan.Li +type Config struct { + MaxIterations int + Temperature float64 + ToolTimeout time.Duration + MaxTokens int + SafetyMode safety.Mode + OutputMaxLength int + MaxContextTokens int + MemoryDir string // Evolver 记忆目录 +} + +// DefaultConfig 默认配置 // Logan.Li +func DefaultConfig() *Config { + return &Config{ + MaxIterations: 16, + Temperature: 0.3, + ToolTimeout: 120 * time.Second, + MaxTokens: 4096, + SafetyMode: safety.ModeBalanced, + OutputMaxLength: 10000, + MaxContextTokens: 8000, + MemoryDir: "data/agentcli", + } +} + +// Agent ReAct 核心引擎 // Logan.Li +type Agent struct { + llm llm.Client + registry *tools.Registry + config *Config + safetyCtl *safety.Controller + evolver *evolver.EvolverEngine + callback EventCallback + totalTokens int + mu sync.Mutex +} + +// NewAgent 创建引擎。evolver 为 nil 时降级(无自学习)。 // Logan.Li +func NewAgent(client llm.Client, registry *tools.Registry, cfg *Config, sc *safety.Controller, ev *evolver.EvolverEngine) *Agent { + if cfg == nil { + cfg = DefaultConfig() + } + if sc == nil { + sc = safety.NewController(cfg.SafetyMode) + } + if cfg.MaxContextTokens <= 0 { + cfg.MaxContextTokens = 8000 + } + return &Agent{llm: client, registry: registry, config: cfg, safetyCtl: sc, evolver: ev} +} + +// SetCallback 注入事件回调 // Logan.Li +func (a *Agent) SetCallback(cb EventCallback) { a.callback = cb } + +// emit 安全触发回调 // Logan.Li +func (a *Agent) emit(e Event) { + if a.callback != nil { + a.callback(e) + } +} + +// Run 执行单次任务(ReAct 循环 + Evolver 集成) // Logan.Li +func (a *Agent) Run(ctx context.Context, query, serverInfo string) (*tools.Result, error) { + a.totalTokens = 0 + start := time.Now() + + // Evolver PREDICT:获取历史经验注入 system prompt + evolveCtx := "" + if a.evolver != nil { + evolveCtx = a.evolver.GetContextForPrompt(query) + } + + // 工具描述 + toolDescs := a.buildToolDescriptions() + systemPrompt := prompt.BuildSystemPrompt(serverInfo, toolDescs, evolveCtx) + + messages := []llm.Message{ + {Role: "system", Content: systemPrompt}, + {Role: "user", Content: query}, + } + llmTools := a.registry.ToLLMTools() + + toolCallHistory := make(map[string]int) // 循环检测 + var toolRecords []evolver.ToolCallRecord + var consecutiveFailures int + var totalToolCalls int + + for iter := 0; iter < a.config.MaxIterations; iter++ { + // 检查中断 + if err := ctx.Err(); err != nil { + return nil, err + } + + a.emit(Event{Type: EventProgress, Round: iter + 1}) + + // 自适应检查点 + if hint := a.checkAdaptiveCheckpoint(iter, toolCallHistory, consecutiveFailures, totalToolCalls); hint != "" { + messages = append(messages, llm.Message{Role: "user", Content: hint}) + } + + // 裁剪历史 + trimmed := a.trimMessages(messages) + + // 调用 LLM + resp, err := a.llm.Complete(ctx, &llm.CompletionRequest{ + Messages: trimmed, + Tools: llmTools, + Temperature: a.config.Temperature, + MaxTokens: a.config.MaxTokens, + }) + if err != nil { + a.emit(Event{Type: EventError, Message: "LLM 调用失败: " + err.Error()}) + return nil, fmt.Errorf("LLM调用失败: %w", err) + } + + a.totalTokens += resp.Usage.TotalTokens + a.emit(Event{Type: EventProgress, Tokens: a.totalTokens}) + + // 推送思考文本 + if strings.TrimSpace(resp.Message.Content) != "" { + a.emit(Event{Type: EventThought, Round: iter + 1, Thought: resp.Message.Content}) + } + messages = append(messages, resp.Message) + + // 无工具调用 → 最终答案 + if len(resp.Message.ToolCalls) == 0 { + answer := resp.Message.Content + a.emit(Event{Type: EventDone, Answer: answer, Tokens: a.totalTokens}) + a.triggerEvolve(ctx, query, serverInfo, answer, toolRecords, true, start) + return &tools.Result{Success: true, Output: answer}, nil + } + + // 处理工具调用 + observations, records, failInc := a.processToolCalls(ctx, resp.Message.ToolCalls, toolCallHistory, iter+1) + toolRecords = append(toolRecords, records...) + messages = append(messages, observations...) + totalToolCalls += len(resp.Message.ToolCalls) + consecutiveFailures = a.updateConsecutiveFailures(observations, consecutiveFailures) + failInc + } + + // 达到最大轮次 + msg := fmt.Sprintf("达到最大迭代次数(%d),任务未能完成。可能任务过于复杂或需要更多步骤。", a.config.MaxIterations) + a.emit(Event{Type: EventError, Message: msg, Tokens: a.totalTokens}) + a.triggerEvolve(ctx, query, serverInfo, "", toolRecords, false, start) + return nil, fmt.Errorf("%s", msg) +} + +// processToolCalls 处理工具调用,返回 observation 消息 + 记录 // Logan.Li +func (a *Agent) processToolCalls(ctx context.Context, calls []llm.ToolCall, history map[string]int, round int) ([]llm.Message, []evolver.ToolCallRecord, int) { + obs := make([]llm.Message, 0, len(calls)) + records := make([]evolver.ToolCallRecord, 0, len(calls)) + var failInc int + + for _, tc := range calls { + key := tc.Function.Name + ":" + tc.Function.Arguments + history[key]++ + + // 循环检测 + if history[key] > 3 { + obs = append(obs, llm.Message{Role: "tool", ToolCallID: tc.ID, Name: tc.Function.Name, + Content: "错误: 检测到循环调用,相同参数已调用超过3次,请换一种方式"}) + continue + } + + tool, err := a.registry.Get(tc.Function.Name) + if err != nil { + obs = append(obs, llm.Message{Role: "tool", ToolCallID: tc.ID, Name: tc.Function.Name, + Content: fmt.Sprintf("错误: 工具 '%s' 未找到", tc.Function.Name)}) + continue + } + + var args map[string]interface{} + if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { + obs = append(obs, llm.Message{Role: "tool", ToolCallID: tc.ID, Name: tc.Function.Name, + Content: "错误: 参数解析失败: " + err.Error()}) + continue + } + + risk := a.toolRisk(tool, args) + a.emit(Event{Type: EventToolCall, Round: round, ToolName: tc.Function.Name, ToolArgs: args, Risk: risk.Display()}) + + // 安全检查(高危触发 confirmFn,由 controller 异步确认) + approved, err := a.safetyCtl.Check(tool, args) + if err != nil { + obs = append(obs, llm.Message{Role: "tool", ToolCallID: tc.ID, Name: tc.Function.Name, + Content: "安全检查失败: " + err.Error()}) + continue + } + if !approved { + obs = append(obs, llm.Message{Role: "tool", ToolCallID: tc.ID, Name: tc.Function.Name, + Content: "操作已被用户拒绝执行"}) + a.emit(Event{Type: EventToolResult, Round: round, ToolName: tc.Function.Name, + Output: "(用户拒绝执行)", Success: false}) + continue + } + + // 执行 + toolStart := time.Now() + toolCtx, cancel := context.WithTimeout(ctx, a.config.ToolTimeout) + result, execErr := tool.Execute(toolCtx, args) + cancel() + dur := time.Since(toolStart) + + var output, errMsg string + success := false + if result != nil { + output, errMsg, success = result.Output, result.Error, result.Success + } + if execErr != nil { + errMsg = execErr.Error() + success = false + } + + // 输出截断 + if len(output) > a.config.OutputMaxLength { + output = output[:a.config.OutputMaxLength] + + fmt.Sprintf("\n[输出已截断,原始 %d 字符]", len(output)) + } + + a.emit(Event{Type: EventToolResult, Round: round, ToolName: tc.Function.Name, + Output: output, Success: success, Duration: dur.Truncate(time.Millisecond).String()}) + + records = append(records, evolver.ToolCallRecord{ + ToolName: tc.Function.Name, Args: args, Output: output, + Duration: dur, Success: success, RiskLevel: int(risk), + }) + + // 构建 observation + var content string + switch { + case execErr != nil: + content = "执行错误: " + execErr.Error() + failInc++ + case !success: + content = fmt.Sprintf("执行失败: %s\n输出: %s", errMsg, output) + failInc++ + default: + content = output + } + obs = append(obs, llm.Message{Role: "tool", ToolCallID: tc.ID, Name: tc.Function.Name, Content: content}) + } + return obs, records, failInc +} + +// triggerEvolve 异步触发进化(带超时 + panic 恢复,绝不阻塞主循环) // Logan.Li +func (a *Agent) triggerEvolve(ctx context.Context, query, serverHost, answer string, records []evolver.ToolCallRecord, success bool, start time.Time) { + if a.evolver == nil { + return + } + exec := &evolver.TaskExecution{ + Query: query, ToolCalls: records, TotalSteps: len(records), + TotalTokens: a.totalTokens, Duration: time.Since(start), + Success: success, FinalAnswer: answer, Timestamp: time.Now(), ServerHost: serverHost, + } + go func() { + defer func() { _ = recover() }() + eCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _ = a.evolver.Evolve(eCtx, exec) + }() +} + +// buildToolDescriptions 工具描述文本 // Logan.Li +func (a *Agent) buildToolDescriptions() string { + var sb strings.Builder + for _, t := range a.registry.List() { + sb.WriteString(fmt.Sprintf("- %s: %s\n", t.Name(), t.Description())) + } + return sb.String() +} + +// toolRisk 取工具风险(动态优先) // Logan.Li +func (a *Agent) toolRisk(tool tools.Tool, args map[string]interface{}) tools.RiskLevel { + if dt, ok := tool.(tools.DynamicRiskTool); ok { + return dt.RiskLevelForArgs(args) + } + return tool.RiskLevel() +} + +// checkAdaptiveCheckpoint 自适应检查点(iter 3/7/11 注入引导) // Logan.Li +func (a *Agent) checkAdaptiveCheckpoint(iter int, history map[string]int, consecFail, totalCalls int) string { + switch iter { + case 3: + repeat := 0 + for _, c := range history { + if c > 1 { + repeat += c - 1 + } + } + if totalCalls > 0 && (float64(repeat)/float64(totalCalls) > 0.5 || consecFail >= 3) { + return "【系统提示】检测到重复使用相同工具或连续失败,请尝试换一种思路或不同命令。" + } + case 7: + return "【系统提示】已执行到中段,请总结进展。信息足够就直接给出最终答案,否则说明剩余步骤。" + case 11: + return fmt.Sprintf("【系统提示】仅剩 %d 步,请立即整理已有信息给出最终答案,不要再调用新工具。", a.config.MaxIterations-iter-1) + } + return "" +} + +// updateConsecutiveFailures 连续失败计数 // Logan.Li +func (a *Agent) updateConsecutiveFailures(obs []llm.Message, current int) int { + errs := 0 + for _, o := range obs { + c := o.Content + if strings.HasPrefix(c, "错误") || strings.HasPrefix(c, "失败") || strings.HasPrefix(c, "执行错误") || strings.HasPrefix(c, "执行失败") { + errs++ + } + } + if errs == 0 { + return 0 + } + return current + errs +} + +// trimMessages 裁剪历史(保留 system + 最近消息,token 不超 90%) // Logan.Li +func (a *Agent) trimMessages(msgs []llm.Message) []llm.Message { + if len(msgs) <= 1 { + return msgs + } + maxT := a.config.MaxContextTokens * 9 / 10 + sysT := estimateTokens(msgs[0].Content) + kept := make([]llm.Message, 0, len(msgs)) + keptT := 0 + for i := len(msgs) - 1; i >= 1; i-- { + t := estimateTokens(msgs[i].Content) + if sysT+keptT+t > maxT { + break + } + keptT += t + kept = append([]llm.Message{msgs[i]}, kept...) + } + // 最少保留最近 4 条,避免上下文过短 + if len(kept) < 4 && len(msgs) > 4 { + kept = msgs[len(msgs)-4:] + } + result := append([]llm.Message{msgs[0]}, kept...) + return ensureToolMessageIntegrity(result) +} + +// ensureToolMessageIntegrity 确保 tool 消息前面有对应 assistant(tool_calls) // Logan.Li +func ensureToolMessageIntegrity(msgs []llm.Message) []llm.Message { + validIDs := make(map[string]bool) + for _, m := range msgs { + if m.Role == "assistant" { + for _, tc := range m.ToolCalls { + validIDs[tc.ID] = true + } + } + } + out := make([]llm.Message, 0, len(msgs)) + for i, m := range msgs { + if m.Role == "tool" { + if !validIDs[m.ToolCallID] || i == 0 || msgs[i-1].Role != "assistant" { + continue + } + matched := false + for _, tc := range msgs[i-1].ToolCalls { + if tc.ID == m.ToolCallID { + matched = true + break + } + } + if !matched { + continue + } + } + out = append(out, m) + } + return out +} + +// estimateTokens 粗略 token 估算(中文约 1 字/token,英文约 1/4,折中 rune/3) // Logan.Li +func estimateTokens(s string) int { + return len([]rune(s))/3 + 1 +} + +// Close 释放资源 // Logan.Li +func (a *Agent) Close() { + if a.registry != nil { + // Registry 无 Closer 实现(Web 工具无连接池),保留接口位 + } +} diff --git a/backend/pkg/agentcli/evolver/engine.go b/backend/pkg/agentcli/evolver/engine.go new file mode 100644 index 0000000..26e2528 --- /dev/null +++ b/backend/pkg/agentcli/evolver/engine.go @@ -0,0 +1,271 @@ +// engine.go - 自进化引擎(Evolver)核心 // Logan.Li +// 保留 opsxcli 13 步进化循环的思想,精简为核心步骤: +// OBSERVE → CLASSIFY → LEARN → STORE → MEMORIZE → PREDICT(注入下次 Prompt)。 +// 异步执行、失败优雅降级(LLM 不可用退化为规则生成),绝不影响主 ReAct 循环。 +package evolver + +import ( + "context" + "fmt" + "strings" + "time" + + "ezops/pkg/agentcli/llm" +) + +// ToolCallRecord 工具调用记录(供 Evolver 观察学习) // Logan.Li +type ToolCallRecord struct { + ToolName string `json:"tool_name"` + Args map[string]interface{} `json:"args"` + Output string `json:"output"` + Duration time.Duration `json:"duration"` + Success bool `json:"success"` + RiskLevel int `json:"risk_level"` +} + +// TaskExecution 一次任务的完整执行记录 // Logan.Li +type TaskExecution struct { + Query string `json:"query"` + ToolCalls []ToolCallRecord `json:"tool_calls"` + TotalSteps int `json:"total_steps"` + TotalTokens int `json:"total_tokens"` + Duration time.Duration `json:"duration"` + Success bool `json:"success"` + FinalAnswer string `json:"final_answer"` + Timestamp time.Time `json:"timestamp"` + ServerHost string `json:"server_host"` // 用于环境记忆 +} + +// EvolveResult 进化结果(会话级回灌 Prompt) // Logan.Li +type EvolveResult struct { + TaskType string `json:"task_type"` + LearnedHint string `json:"learned_hint"` + ToolSequence []string `json:"tool_sequence"` + ExperienceAdded bool `json:"experience_added"` + Consolidated bool `json:"consolidated"` +} + +// EvolverEngine 自进化引擎 // Logan.Li +type EvolverEngine struct { + mem *Memories + llm llm.Client // 可选,用于提炼经验/事实 + memDir string +} + +// NewEvolverEngine 创建进化引擎(llm 可为 nil,退化为规则学习) // Logan.Li +func NewEvolverEngine(memDir string, client llm.Client) (*EvolverEngine, error) { + if memDir == "" { + memDir = "data/agentcli" + } + if err := ensureDir(memDir); err != nil { + return nil, fmt.Errorf("初始化记忆目录失败: %w", err) + } + return &EvolverEngine{ + mem: NewMemories(memDir), + llm: client, + memDir: memDir, + }, nil +} + +// Evolve 执行一次进化(观察任务 → 学习经验 → 存储) // Logan.Li +// 设计为幂等、可降级:任何子步骤失败都不返回 error 给调用方(仅影响学习质量)。 +func (e *EvolverEngine) Evolve(ctx context.Context, exec *TaskExecution) *EvolveResult { + if exec == nil { + return nil + } + result := &EvolveResult{} + + // Step 1: OBSERVE —— 提取工具序列 + for _, tc := range exec.ToolCalls { + result.ToolSequence = append(result.ToolSequence, tc.ToolName) + } + + // Step 2: CLASSIFY —— 任务类型分类(关键词规则,无需 LLM) + result.TaskType = classifyTask(exec.Query, exec.ToolCalls) + + // Step 3: LEARN —— 生成经验 hint(优先 LLM,降级规则) + result.LearnedHint = e.learnHint(ctx, exec, result.TaskType) + + // Step 4: STORE —— 存入经验记忆 + 环境记忆(主机) + exp := ExperienceEntry{ + TaskType: result.TaskType, + Query: truncate(exec.Query, 200), + Hint: result.LearnedHint, + ToolSequence: result.ToolSequence, + Success: exec.Success, + Timestamp: exec.Timestamp.Unix(), + } + if err := e.mem.AddExperience(exp); err == nil { + result.ExperienceAdded = true + } + if exec.ServerHost != "" { + _ = e.mem.AddHost(exec.ServerHost) // 失败忽略 + } + + // Step 5: MEMORIZE —— LLM 提炼持久事实(可选,失败降级) + if e.llm != nil && exec.Success && exec.FinalAnswer != "" { + go e.memorizeFact(ctx, exec) // 异步,不阻塞进化主流程 + } + + return result +} + +// learnHint 生成经验提示(LLM 优先,降级规则) // Logan.Li +func (e *EvolverEngine) learnHint(ctx context.Context, exec *TaskExecution, taskType string) string { + tools := strings.Join(uniqueStrings(resultToolNames(exec.ToolCalls)), " → ") + + // 尝试用 LLM 提炼 + if e.llm != nil { + if hint := e.llmLearnHint(ctx, exec, taskType, tools, exec.Success); hint != "" { + return hint + } + } + + // 降级:规则生成 + if exec.Success { + return fmt.Sprintf("完成「%s」类任务时,工具序列 %s 最终成功,可作为参考。", taskType, tools) + } + return fmt.Sprintf("尝试「%s」类任务时,工具序列 %s 未成功,下次可换用不同命令或思路。", taskType, tools) +} + +// llmLearnHint 调用 LLM 提炼一句经验 // Logan.Li +func (e *EvolverEngine) llmLearnHint(ctx context.Context, exec *TaskExecution, taskType, tools string, success bool) string { + result := "失败" + if success { + result = "成功" + } + prompt := fmt.Sprintf( + "你是运维经验提炼器。根据以下任务执行记录,用一句中文(不超过80字)提炼经验提示,"+ + "说明下次做同类任务的关键要点或推荐工具顺序。只返回经验文本,不要多余解释。\n"+ + "任务: %s\n工具序列: %s\n结果: %s", + truncate(exec.Query, 150), tools, result) + resp, err := e.llm.Complete(ctx, &llm.CompletionRequest{ + Messages: []llm.Message{{Role: "user", Content: prompt}}, + }) + if err != nil || resp == nil { + return "" + } + return strings.TrimSpace(truncate(resp.Message.Content, 120)) +} + +// memorizeFact 异步提取持久事实存入事实记忆 // Logan.Li +func (e *EvolverEngine) memorizeFact(ctx context.Context, exec *TaskExecution) { + defer func() { _ = recover() }() + prompt := fmt.Sprintf( + "从以下运维任务记录中,提炼 1 条值得长期记住的事实(如某主机的固定配置、已知问题、约定)。"+ + "如果没有明显可沉淀的事实,回复「无」。只返回事实本身,一句话。\n任务: %s\n结论: %s", + truncate(exec.Query, 150), truncate(exec.FinalAnswer, 300)) + ctx2, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + resp, err := e.llm.Complete(ctx2, &llm.CompletionRequest{ + Messages: []llm.Message{{Role: "user", Content: prompt}}, + }) + if err != nil || resp == nil { + return + } + fact := strings.TrimSpace(resp.Message.Content) + if fact == "" || strings.HasPrefix(fact, "无") { + return + } + _ = e.mem.AddFact(FactEntry{Fact: truncate(fact, 150), Source: truncate(exec.Query, 80)}) +} + +// GetContextForPrompt 为下一次任务构建记忆上下文(注入 system prompt) // Logan.Li +func (e *EvolverEngine) GetContextForPrompt(query string) string { + taskType := classifyTask(query, nil) + var sb strings.Builder + + if exps := e.mem.FindExperiences(taskType, 3); len(exps) > 0 { + sb.WriteString(fmt.Sprintf(" 【%s 类历史经验】\n", taskType)) + for _, exp := range exps { + mark := "成功" + if !exp.Success { + mark = "未成功" + } + sb.WriteString(fmt.Sprintf(" - (%s) %s\n", mark, exp.Hint)) + } + } + if facts := e.mem.RelevantFacts(query, 3); len(facts) > 0 { + sb.WriteString(" 【已知事实】\n") + for _, f := range facts { + sb.WriteString(fmt.Sprintf(" - %s\n", f.Fact)) + } + } + if hosts := e.mem.Hosts(); len(hosts) > 0 { + sb.WriteString(fmt.Sprintf(" 【已知主机】%s\n", strings.Join(hosts, ", "))) + } + return sb.String() +} + +// GetStats 进化统计(供审计/展示) // Logan.Li +func (e *EvolverEngine) GetStats() map[string]interface{} { + return map[string]interface{}{ + "enabled": true, + "memory": e.mem.CountStats(), + } +} + +// ---- 任务分类(关键词规则) ---- // Logan.Li + +// classifyTask 按 query + 工具调用关键词分类任务类型 // Logan.Li +func classifyTask(query string, calls []ToolCallRecord) string { + text := strings.ToLower(query) + for _, c := range calls { + if cmd, ok := c.Args["command"].(string); ok { + text += " " + strings.ToLower(cmd) + } + } + rules := []struct { + typ string + kws []string + }{ + {"磁盘空间", []string{"disk", "空间", "df", "du", "inode", "磁盘", "满了"}}, + {"网络", []string{"network", "网络", "ping", "netstat", "ss ", "端口", "连接", "tcp", "dns"}}, + {"服务管理", []string{"service", "服务", "systemctl", "nginx", "mysql", "redis", "restart", "启动", "停止"}}, + {"软件安装", []string{"install", "安装", "yum", "apt", "docker", "部署", "deploy", "pip", "npm"}}, + {"进程排查", []string{"process", "进程", "ps ", "kill", "cpu", "top", "卡死", "hang"}}, + {"内存排查", []string{"memory", "内存", "mem", "free", "oom", "swap"}}, + {"日志排查", []string{"log", "日志", "journalctl", "tail", "grep"}}, + {"权限问题", []string{"permission", "权限", "chmod", "chown", "denied", "拒绝", "forbidden"}}, + {"性能调优", []string{"performance", "性能", "负载", "load", "慢", "slow", "latency", "延迟"}}, + {"配置管理", []string{"config", "配置", "conf", "vim", "sed"}}, + } + for _, r := range rules { + for _, kw := range r.kws { + if strings.Contains(text, kw) { + return r.typ + } + } + } + return "通用运维" +} + +// ---- 辅助 ---- // Logan.Li + +func truncate(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n]) + "..." +} + +func resultToolNames(calls []ToolCallRecord) []string { + out := make([]string, 0, len(calls)) + for _, c := range calls { + out = append(out, c.ToolName) + } + return out +} + +func uniqueStrings(in []string) []string { + seen := make(map[string]bool) + out := make([]string, 0, len(in)) + for _, s := range in { + if !seen[s] { + seen[s] = true + out = append(out, s) + } + } + return out +} diff --git a/backend/pkg/agentcli/evolver/memory.go b/backend/pkg/agentcli/evolver/memory.go new file mode 100644 index 0000000..c299c0c --- /dev/null +++ b/backend/pkg/agentcli/evolver/memory.go @@ -0,0 +1,246 @@ +// Package evolver 实现 Agent CLI 的自进化引擎(Evolver)。 // Logan.Li +// 设计参考 opsxcli internal/agent/evolver,保留四层记忆 + 进化循环思想,精简适配为后端库: +// - 记忆持久化用文件(data/agentcli/*.jsonl),与 opsxcli 原生一致,零 DB 依赖。 +// - 进化循环异步执行,失败优雅降级,绝不影响主 ReAct 循环。 +// - LLM 可用时用于提炼经验/事实;不可用时退化为规则生成。 +package evolver + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +// ---- 基础文件存储 ---- // Logan.Li + +// ensureDir 确保目录存在 // Logan.Li +func ensureDir(dir string) error { return os.MkdirAll(dir, 0o755) } + +// readJSONL 读取 JSONL 文件为切片 // Logan.Li +func readJSONL[T any](path string) []T { + raw, err := os.ReadFile(path) + if err != nil || len(raw) == 0 { + return nil + } + var out []T + for _, line := range strings.Split(strings.TrimSpace(string(raw)), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + var item T + if json.Unmarshal([]byte(line), &item) == nil { + out = append(out, item) + } + } + return out +} + +// appendJSONL 追加一行 JSON // Logan.Li +func appendJSONL[T any](path string, item T) error { + b, err := json.Marshal(item) + if err != nil { + return err + } + b = append(b, '\n') + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return err + } + defer f.Close() + _, err = f.Write(b) + return err +} + +// readJSON 读取整个 JSON 文件(失败返回默认值) // Logan.Li +func readJSON[T any](path string, def T) T { + raw, err := os.ReadFile(path) + if err != nil { + return def + } + var v T + if json.Unmarshal(raw, &v) != nil { + return def + } + return v +} + +// writeJSON 写入整个 JSON 文件 // Logan.Li +func writeJSON[T any](path string, v T) error { + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, b, 0o644) +} + +// ---- 四层记忆 ---- // Logan.Li + +// ExperienceEntry 经验记忆条目(最核心,直接注入下次 Prompt) // Logan.Li +type ExperienceEntry struct { + TaskType string `json:"task_type"` + Query string `json:"query"` + Hint string `json:"hint"` // 经验提示 + ToolSequence []string `json:"tool_sequence"` // 推荐工具序列 + Success bool `json:"success"` + Timestamp int64 `json:"timestamp"` +} + +// FactEntry 事实记忆条目(持久事实) // Logan.Li +type FactEntry struct { + Fact string `json:"fact"` + Source string `json:"source"` + Timestamp int64 `json:"timestamp"` +} + +// SkillEntry 程序记忆条目(运维技能/流程) // Logan.Li +type SkillEntry struct { + Name string `json:"name"` + When string `json:"when"` + Steps []string `json:"steps"` + Timestamp int64 `json:"timestamp"` +} + +// Memories 四层记忆集合(线程安全) // Logan.Li +type Memories struct { + dir string + mu sync.Mutex + + expPath string // experience.jsonl + facPath string // factual.jsonl + sklPath string // procedural.jsonl + envPath string // environment.json +} + +// NewMemories 创建四层记忆(dir 为 data/agentcli) // Logan.Li +func NewMemories(dir string) *Memories { + return &Memories{ + dir: dir, + expPath: filepath.Join(dir, "experience.jsonl"), + facPath: filepath.Join(dir, "factual.jsonl"), + sklPath: filepath.Join(dir, "procedural.jsonl"), + envPath: filepath.Join(dir, "environment.json"), + } +} + +// AddExperience 追加经验 // Logan.Li +func (m *Memories) AddExperience(e ExperienceEntry) error { + m.mu.Lock() + defer m.mu.Unlock() + if e.Timestamp == 0 { + e.Timestamp = time.Now().Unix() + } + return appendJSONL(m.expPath, e) +} + +// FindExperiences 按 task_type 查找最近的经验(最多 n 条,优先成功) // Logan.Li +func (m *Memories) FindExperiences(taskType string, n int) []ExperienceEntry { + all := readJSONL[ExperienceEntry](m.expPath) + var matched []ExperienceEntry + for _, e := range all { + if taskType == "" || e.TaskType == taskType { + matched = append(matched, e) + } + } + // 优先成功经验,按时间倒序取前 n + successFirst := make([]ExperienceEntry, 0, len(matched)) + others := make([]ExperienceEntry, 0, len(matched)) + for _, e := range matched { + if e.Success { + successFirst = append(successFirst, e) + } else { + others = append(others, e) + } + } + // 简单逆序(文件本身就是时间顺序,取末尾最新) + pick := func(s []ExperienceEntry) []ExperienceEntry { + if len(s) <= n { + return s + } + return s[len(s)-n:] + } + out := append(pick(successFirst), pick(others)...) + if len(out) > n { + out = out[len(out)-n:] + } + return out +} + +// AddFact 追加事实 // Logan.Li +func (m *Memories) AddFact(f FactEntry) error { + m.mu.Lock() + defer m.mu.Unlock() + if f.Timestamp == 0 { + f.Timestamp = time.Now().Unix() + } + return appendJSONL(m.facPath, f) +} + +// RelevantFacts 返回与 query 关键词相关的事实(最多 n 条) // Logan.Li +func (m *Memories) RelevantFacts(query string, n int) []FactEntry { + all := readJSONL[FactEntry](m.facPath) + if len(all) == 0 { + return nil + } + kw := strings.ToLower(query) + var out []FactEntry + for _, f := range all { + if strings.Contains(strings.ToLower(f.Fact), kw) || strings.Contains(kw, strings.ToLower(f.Fact)) { + out = append(out, f) + } + } + if len(out) > n { + out = out[len(out)-n:] + } + return out +} + +// AddSkill 追加技能 // Logan.Li +func (m *Memories) AddSkill(s SkillEntry) error { + m.mu.Lock() + defer m.mu.Unlock() + if s.Timestamp == 0 { + s.Timestamp = time.Now().Unix() + } + return appendJSONL(m.sklPath, s) +} + +// KnownHosts 已知主机(环境记忆) // Logan.Li +type KnownHosts struct { + Hosts []string `json:"hosts"` +} + +// AddHost 记录已知主机(去重) // Logan.Li +func (m *Memories) AddHost(host string) error { + if host == "" { + return nil + } + m.mu.Lock() + defer m.mu.Unlock() + env := readJSON(m.envPath, KnownHosts{}) + for _, h := range env.Hosts { + if h == host { + return nil + } + } + env.Hosts = append(env.Hosts, host) + return writeJSON(m.envPath, env) +} + +// Hosts 返回已知主机列表 // Logan.Li +func (m *Memories) Hosts() []string { + return readJSON(m.envPath, KnownHosts{}).Hosts +} + +// CountStats 统计各层记忆条目数 // Logan.Li +func (m *Memories) CountStats() map[string]int { + return map[string]int{ + "experience": len(readJSONL[ExperienceEntry](m.expPath)), + "factual": len(readJSONL[FactEntry](m.facPath)), + "skill": len(readJSONL[SkillEntry](m.sklPath)), + "hosts": len(readJSON(m.envPath, KnownHosts{}).Hosts), + } +} diff --git a/backend/pkg/agentcli/llm/openai.go b/backend/pkg/agentcli/llm/openai.go new file mode 100644 index 0000000..4ca038d --- /dev/null +++ b/backend/pkg/agentcli/llm/openai.go @@ -0,0 +1,145 @@ +// openai.go - OpenAI 兼容 LLM 客户端实现 // Logan.Li +// 复用 aiops 既有 LLM 调用模式(model.LLMConfig + resolveEndpoint + /chat/completions + Bearer)。 +// 支持所有 OpenAI 兼容后端:openai / deepseek / qwen / moonshot / ollama / custom。 +package llm + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// Config LLM 客户端配置(endpoint 已由上层解析为完整 base url) // Logan.Li +type Config struct { + Endpoint string // 完整 base url,如 https://api.deepseek.com/v1 + APIKey string // Bearer token + Model string // 模型名,如 deepseek-chat / gpt-4o-mini + Temperature float64 // 采样温度 + MaxTokens int // 单次最大输出 token + Timeout time.Duration // HTTP 超时(0 表示使用默认 180s) +} + +// OpenAIClient OpenAI 兼容客户端 // Logan.Li +type OpenAIClient struct { + cfg Config + client *http.Client +} + +// NewClient 创建 OpenAI 兼容客户端 // Logan.Li +func NewClient(cfg Config) Client { + timeout := cfg.Timeout + if timeout <= 0 { + timeout = 180 * time.Second + } + return &OpenAIClient{ + cfg: cfg, + client: &http.Client{Timeout: timeout}, + } +} + +// Name 返回客户端名称 // Logan.Li +func (c *OpenAIClient) Name() string { return "openai-compatible(" + c.cfg.Model + ")" } + +// openaiRequest OpenAI chat/completions 请求体 // Logan.Li +type openaiRequest struct { + Model string `json:"model"` + Messages []Message `json:"messages"` + Tools []Tool `json:"tools,omitempty"` + ToolChoice string `json:"tool_choice,omitempty"` // "auto" 让模型自主决定 + Temperature float64 `json:"temperature,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` +} + +// openaiResponse OpenAI chat/completions 响应体 // Logan.Li +type openaiResponse struct { + Choices []struct { + Message struct { + Role string `json:"role"` + Content string `json:"content"` + ToolCalls []ToolCall `json:"tool_calls"` + } `json:"message"` + } `json:"choices"` + Usage Usage `json:"usage"` + Error *struct { + Message string `json:"message"` + Type string `json:"type"` + } `json:"error,omitempty"` +} + +// Complete 调用 OpenAI 兼容接口完成一轮补全 // Logan.Li +// 返回 assistant 消息(含思考文本 content 与工具调用计划 tool_calls)+ token 统计。 +func (c *OpenAIClient) Complete(ctx context.Context, req *CompletionRequest) (*CompletionResponse, error) { + payload := openaiRequest{ + Model: c.cfg.Model, + Messages: req.Messages, + Tools: req.Tools, + ToolChoice: "auto", + Temperature: c.cfg.Temperature, + MaxTokens: c.cfg.MaxTokens, + } + if payload.MaxTokens <= 0 { + payload.MaxTokens = 4096 + } + body, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("序列化请求失败: %w", err) + } + + url := c.cfg.Endpoint + "/chat/completions" + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("构造请求失败: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+c.cfg.APIKey) + + resp, err := c.client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("LLM 连接失败: %w", err) + } + defer resp.Body.Close() + + raw, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("LLM 返回错误 (HTTP %d): %s", resp.StatusCode, extractErrorMessage(raw)) + } + + var parsed openaiResponse + if err := json.Unmarshal(raw, &parsed); err != nil { + return nil, fmt.Errorf("解析 LLM 响应失败: %w", err) + } + if parsed.Error != nil && parsed.Error.Message != "" { + return nil, fmt.Errorf("LLM 错误: %s", parsed.Error.Message) + } + if len(parsed.Choices) == 0 { + return nil, fmt.Errorf("LLM 未返回任何结果") + } + + choice := parsed.Choices[0].Message + return &CompletionResponse{ + Model: c.cfg.Model, + Message: Message{ + Role: choice.Role, + Content: choice.Content, + ToolCalls: choice.ToolCalls, + }, + Usage: parsed.Usage, + }, nil +} + +// extractErrorMessage 从上游错误响应提取 message 字段 // Logan.Li +func extractErrorMessage(raw []byte) string { + var m struct { + Error struct { + Message string `json:"message"` + } `json:"error"` + } + if json.Unmarshal(raw, &m) == nil && m.Error.Message != "" { + return m.Error.Message + } + return string(raw) +} diff --git a/backend/pkg/agentcli/llm/types.go b/backend/pkg/agentcli/llm/types.go new file mode 100644 index 0000000..4aec90a --- /dev/null +++ b/backend/pkg/agentcli/llm/types.go @@ -0,0 +1,73 @@ +// Package llm 提供 Agent CLI 引擎使用的 LLM 抽象层。 // Logan.Li +// 仅保留与 ReAct 循环相关的最小接口:消息、工具调用、补全请求/响应、Client。 +// 设计参考 opsxcli/internal/llm,剥离 CLI 配置向导与多 Provider 文件配置, +// 配置由上层(controller)从 model.LLMConfig 解析后注入。 +package llm + +import "context" + +// Message 聊天消息(OpenAI 兼容格式) // Logan.Li +type Message struct { + Role string `json:"role"` // system / user / assistant / tool + Content string `json:"content"` // 消息内容 + Name string `json:"name,omitempty"` // tool 消息对应的工具名 + ToolCallID string `json:"tool_call_id,omitempty"` // tool 消息对应的 tool_call id + ToolCalls []ToolCall `json:"tool_calls,omitempty"` // assistant 消息携带的工具调用 +} + +// ToolCall 工具调用 // Logan.Li +type ToolCall struct { + ID string `json:"id"` + Type string `json:"type"` // 固定 "function" + Function FunctionCall `json:"function"` +} + +// FunctionCall 函数调用(名称 + JSON 字符串参数) // Logan.Li +type FunctionCall struct { + Name string `json:"name"` + Arguments string `json:"arguments"` // JSON 字符串 +} + +// Tool LLM 工具定义(function calling) // Logan.Li +type Tool struct { + Type string `json:"type"` // 固定 "function" + Function ToolFunction `json:"function"` +} + +// ToolFunction 工具函数定义(给 LLM 看的 name/description/parameters) // Logan.Li +type ToolFunction struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters map[string]interface{} `json:"parameters"` // JSON Schema +} + +// CompletionRequest 补全请求参数 // Logan.Li +type CompletionRequest struct { + Messages []Message `json:"messages"` + Tools []Tool `json:"tools,omitempty"` + Temperature float64 `json:"temperature,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` +} + +// CompletionResponse 补全响应(取首个 choice) // Logan.Li +type CompletionResponse struct { + ID string `json:"id"` + Model string `json:"model"` + Message Message `json:"message"` + Usage Usage `json:"usage"` +} + +// Usage token 使用统计 // Logan.Li +type Usage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` +} + +// Client LLM 客户端接口 // Logan.Li +type Client interface { + // Complete 一次性补全(返回思考文本 + 工具调用计划)。 + Complete(ctx context.Context, req *CompletionRequest) (*CompletionResponse, error) + // Name 返回客户端名称(用于日志/审计)。 + Name() string +} diff --git a/backend/pkg/agentcli/prompt/prompt.go b/backend/pkg/agentcli/prompt/prompt.go new file mode 100644 index 0000000..c8c99f8 --- /dev/null +++ b/backend/pkg/agentcli/prompt/prompt.go @@ -0,0 +1,73 @@ +// Package prompt 构建 Agent CLI 的 System Prompt(五层架构 + 动态记忆注入)。 // Logan.Li +// 设计参考 opsxcli internal/agent/prompt,角色改写为 aiops Web 运维 Agent 语境。 +package prompt + +import ( + "fmt" + "strings" +) + +// BuildSystemPrompt 构建五层 System Prompt。 // Logan.Li +// - Layer 1: 角色定位(aiops 智能运维 Agent) +// - Layer 2: 安全规则(黑名单/高危确认/优先只读) +// - Layer 3: 工具说明(由 registry 动态生成) +// - Layer 4: 工作方式(ReAct 循环:思考→执行→观察→失败则换法重试→直到解决) +// - Layer 5: 记忆注入(Evolver 提供的历史经验) +// +// serverInfo:目标主机摘要;toolDescs:工具描述文本;evolveContext:历史经验。 +func BuildSystemPrompt(serverInfo, toolDescs, evolveContext string) string { + var sb strings.Builder + + // Layer 1: 角色 + sb.WriteString("你是 aiops 平台的 Agent CLI —— 一个企业级智能运维 Agent," + + "类似 Claude Code / Codex,但运行在 Web 页面上,通过 SSH 工具远程操作真实服务器。\n") + sb.WriteString("你的职责是:根据用户的自然语言任务描述,自主在目标服务器上排查故障、安装与配置软件、修复问题," + + "并通过 WebSocket 把每一步思考、执行的命令和输出实时反馈给用户。\n\n") + + // 目标主机 + if strings.TrimSpace(serverInfo) != "" { + sb.WriteString("【目标主机】\n" + serverInfo + "\n\n") + } + + // Layer 2: 安全规则 + sb.WriteString("【安全规则】\n") + sb.WriteString("1. 破坏性命令(rm -rf /、mkfs、dd 覆写磁盘等)被严格禁止,绝不执行。\n") + sb.WriteString("2. 高危操作(reboot、systemctl restart、kill、卸载软件等)需要用户确认后才能执行。\n") + sb.WriteString("3. 排障时优先使用只读命令(df/ps/cat/grep/tail/systemctl status/journalctl)收集信息,确认根因后再动手修复。\n") + sb.WriteString("4. 不要执行任何你不确定后果的命令;宁可多问用户,也不盲目操作。\n\n") + + // Layer 3: 工具 + if strings.TrimSpace(toolDescs) != "" { + sb.WriteString("【可用工具】\n" + toolDescs + "\n\n") + } + + // Layer 4: 工作方式(ReAct) + sb.WriteString("【工作方式(ReAct 循环)】\n") + sb.WriteString("你采用「思考-行动-观察」循环解决任务:\n") + sb.WriteString("1. 思考:分析当前状态,决定下一步。在回复中先用简短文字说明你的判断和计划。\n") + sb.WriteString("2. 行动:调用 ssh_execute 执行命令(一次只做关键的、可验证的一步)。\n") + sb.WriteString("3. 观察:仔细阅读命令输出(含 exit_code,0 表示成功)。\n") + sb.WriteString("4. 循环:如果命令失败或问题未解决,分析错误原因,换一种方法重试,直到真正解决。不要因一次失败就放弃。\n") + sb.WriteString("5. 收尾:问题解决后,用清晰的中文总结:问题根因、你做了什么、最终结果、后续建议。然后停止调用工具。\n\n") + + sb.WriteString("【重要】务必真正解决问题,而不是假装解决。每一步都要基于实际命令输出判断," + + "禁止编造不存在的输出。命令超时或连接失败时,向用户说明并建议排查方向。\n\n") + + // Layer 5: 记忆注入 + if strings.TrimSpace(evolveContext) != "" { + sb.WriteString("【历史经验(来自自进化引擎)】\n" + evolveContext + "\n\n") + } + + // 输出要求 + sb.WriteString("【输出要求】使用简体中文,简洁专业。思考与命令穿插呈现,避免冗长空话。") + return sb.String() +} + +// FormatToolDescriptions 把工具名+描述格式化为列表文本 // Logan.Li +func FormatToolDescriptions(items []struct{ Name, Desc string }) string { + var sb strings.Builder + for _, it := range items { + sb.WriteString(fmt.Sprintf("- %s: %s\n", it.Name, it.Desc)) + } + return sb.String() +} diff --git a/backend/pkg/agentcli/safety/safety.go b/backend/pkg/agentcli/safety/safety.go new file mode 100644 index 0000000..475edc0 --- /dev/null +++ b/backend/pkg/agentcli/safety/safety.go @@ -0,0 +1,89 @@ +// Package safety 提供 Agent CLI 的安全控制层。 // Logan.Li +// 设计参考 opsxcli internal/agent/safety,适配 Web 场景:确认动作改为异步回调 +// (由 controller 实现 confirmFn:推送 WS confirm 事件并阻塞等待前端回传)。 +// 风险评估逻辑(黑名单 + AssessBashRisk)在 tools 包,本包只做"是否需确认"的策略。 +package safety + +import ( + "ezops/pkg/agentcli/tools" +) + +// Mode 安全模式 // Logan.Li +type Mode string + +const ( + ModeStrict Mode = "strict" // medium+ 需确认 + ModeBalanced Mode = "balanced" // high+ 需确认(默认) + ModePermissive Mode = "permissive" // 仅 critical 处理 +) + +// ConfirmFn 确认回调:由 controller 实现,返回用户是否批准执行。 // Logan.Li +// risk 为动态评估的风险等级,args 为工具参数(含具体 command),供前端展示。 +type ConfirmFn func(toolName string, args map[string]interface{}, risk tools.RiskLevel) (bool, error) + +// Controller 安全控制器 // Logan.Li +type Controller struct { + mode Mode + confirmFn ConfirmFn + autoApprove bool // 测试用:自动批准(危险,生产禁用) +} + +// NewController 创建安全控制器 // Logan.Li +func NewController(mode Mode) *Controller { + if mode == "" { + mode = ModeBalanced + } + return &Controller{mode: mode} +} + +// SetConfirmFn 注入确认回调 // Logan.Li +func (c *Controller) SetConfirmFn(fn ConfirmFn) { c.confirmFn = fn } + +// SetAutoApprove 设置自动批准(仅测试) // Logan.Li +func (c *Controller) SetAutoApprove(b bool) { c.autoApprove = b } + +// riskThreshold 各模式下的确认阈值 // Logan.Li +func (c *Controller) riskThreshold() tools.RiskLevel { + switch c.mode { + case ModeStrict: + return tools.RiskMedium + case ModePermissive: + return tools.RiskCritical + default: + return tools.RiskHigh + } +} + +// Check 执行前安全检查,返回是否允许执行。 // Logan.Li +// 策略: +// 1. 命中危险命令黑名单(Critical)→ 直接拒绝(硬红线,不可确认放行)。 +// 2. 按模式阈值,达到等级 → 调 confirmFn 请用户确认。 +// 3. AutoApprove → 跳过确认。 +func (c *Controller) Check(tool tools.Tool, args map[string]interface{}) (bool, error) { + risk := assessToolRisk(tool, args) + + // 黑名单硬红线:rm -rf /、mkfs 等绝不允许 + if risk >= tools.RiskCritical { + return false, nil + } + + // 未达阈值或自动批准:直接放行 + if c.autoApprove || risk < c.riskThreshold() { + return true, nil + } + + // 达到阈值:请求用户确认 + if c.confirmFn == nil { + // 无确认回调时,保守拒绝(Web 场景应始终注入 confirmFn) + return false, nil + } + return c.confirmFn(tool.Name(), args, risk) +} + +// assessToolRisk 取工具实际风险:优先动态评估(ssh_execute 看命令),否则静态。 // Logan.Li +func assessToolRisk(tool tools.Tool, args map[string]interface{}) tools.RiskLevel { + if dt, ok := tool.(tools.DynamicRiskTool); ok { + return dt.RiskLevelForArgs(args) + } + return tool.RiskLevel() +} diff --git a/backend/pkg/agentcli/tools/builtin.go b/backend/pkg/agentcli/tools/builtin.go new file mode 100644 index 0000000..a9f35f8 --- /dev/null +++ b/backend/pkg/agentcli/tools/builtin.go @@ -0,0 +1,106 @@ +// builtin.go - 内置只读工具 // Logan.Li +// analyze_output:对冗长命令输出做摘要/错误提取,避免 token 爆炸,辅助 LLM 聚焦关键线索。 +package tools + +import ( + "context" + "fmt" + "strings" +) + +// AnalyzeOutputTool 输出分析工具(只读,Safe) // Logan.Li +type AnalyzeOutputTool struct{} + +// NewAnalyzeOutputTool 创建输出分析工具 // Logan.Li +func NewAnalyzeOutputTool() *AnalyzeOutputTool { return &AnalyzeOutputTool{} } + +// Name 工具名 // Logan.Li +func (t *AnalyzeOutputTool) Name() string { return "analyze_output" } + +// Description 给 LLM 的说明 // Logan.Li +func (t *AnalyzeOutputTool) Description() string { + return "分析一段(可能很长的)命令输出文本:统计行数、提取疑似错误/告警行、必要时截断保留首尾。" + + "当 ssh_execute 返回内容过长时调用此工具提炼关键信息再继续推理。" +} + +// Parameters 参数定义 // Logan.Li +func (t *AnalyzeOutputTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "output": map[string]interface{}{ + "type": "string", + "description": "待分析的文本输出", + }, + "focus": map[string]interface{}{ + "type": "string", + "description": "可选关注点,如 error、memory、disk", + }, + }, + "required": []string{"output"}, + } +} + +// RiskLevel 静态风险(只读安全) // Logan.Li +func (t *AnalyzeOutputTool) RiskLevel() RiskLevel { return RiskSafe } + +// Execute 执行输出分析 // Logan.Li +func (t *AnalyzeOutputTool) Execute(ctx context.Context, args map[string]interface{}) (*Result, error) { + output := StringParam(args, "output") + focus := strings.ToLower(StringParam(args, "focus")) + if output == "" { + return &Result{Success: true, Output: "(空输出)"}, nil + } + + lines := strings.Split(output, "\n") + total := len(lines) + + // 超长截断:保留前 40 + 后 40 行,中间用省略标注 + truncated := false + if total > 120 { + head := lines[:40] + tail := lines[total-40:] + lines = append(append(head, fmt.Sprintf("... [已省略 %d 行] ...", total-80)), tail...) + truncated = true + } + + // 提取疑似错误/告警行 + errorKeywords := []string{"error", "fail", "exception", "traceback", "denied", + "permission", "timeout", "refused", "unavailable", "critical", "fatal", + "错误", "失败", "拒绝", "超时", "无法", "异常"} + if focus != "" { + errorKeywords = append([]string{focus}, errorKeywords...) + } + var errorLines []string + for _, line := range lines { + low := strings.ToLower(line) + for _, kw := range errorKeywords { + if strings.Contains(low, kw) { + errorLines = append(errorLines, strings.TrimSpace(line)) + break + } + } + } + if len(errorLines) > 20 { + errorLines = errorLines[:20] + } + + // 组装分析摘要 + var sb strings.Builder + sb.WriteString(fmt.Sprintf("总行数: %d", total)) + if truncated { + sb.WriteString("(已截断保留首尾)") + } + sb.WriteString("\n") + if len(errorLines) > 0 { + sb.WriteString(fmt.Sprintf("疑似错误/告警行 (%d):\n", len(errorLines))) + for _, l := range errorLines { + sb.WriteString(" " + l + "\n") + } + } else { + sb.WriteString("未发现明显错误关键词\n") + } + sb.WriteString("\n输出片段:\n" + strings.Join(lines, "\n")) + + return &Result{Success: true, Output: sb.String()}, nil +} diff --git a/backend/pkg/agentcli/tools/registry.go b/backend/pkg/agentcli/tools/registry.go new file mode 100644 index 0000000..6ef27f7 --- /dev/null +++ b/backend/pkg/agentcli/tools/registry.go @@ -0,0 +1,169 @@ +// Package tools 提供 Agent CLI 引擎的工具注册中心与工具接口。 // Logan.Li +// 设计参考 opsxcli/internal/agent/tools,剥离 CLI 专属工具(local_bash 本地执行、scp)。 +// Web 版聚焦远程运维:ssh_execute(目标主机执行)+ analyze_output(输出分析)。 +// 风险评估逻辑(黑名单 + AssessBashRisk)置于本包,避免与 safety 包形成循环依赖。 +package tools + +import ( + "context" + "fmt" + "sync" + + "ezops/pkg/agentcli/llm" +) + +// RiskLevel 工具风险等级 // Logan.Li +type RiskLevel int + +const ( + RiskSafe RiskLevel = iota // 只读操作(cat/grep/df/ps) + RiskLow // 低风险(mkdir/touch) + RiskMedium // 中风险(chmod/文件修改) + RiskHigh // 高风险(远程服务控制/reboot) + RiskCritical // 危险操作(rm -rf //mkfs/dd) +) + +// String 返回风险等级字符串 // Logan.Li +func (r RiskLevel) String() string { + switch r { + case RiskSafe: + return "safe" + case RiskLow: + return "low" + case RiskMedium: + return "medium" + case RiskHigh: + return "high" + case RiskCritical: + return "critical" + default: + return "unknown" + } +} + +// Display 返回风险等级中文显示 // Logan.Li +func (r RiskLevel) Display() string { + switch r { + case RiskSafe: + return "安全" + case RiskLow: + return "低风险" + case RiskMedium: + return "中风险" + case RiskHigh: + return "高风险" + case RiskCritical: + return "危险" + default: + return "未知" + } +} + +// Result 工具执行结果 // Logan.Li +type Result struct { + Success bool `json:"success"` + Output string `json:"output"` + Error string `json:"error,omitempty"` +} + +// Tool 核心工具接口(5 方法) // Logan.Li +type Tool interface { + Name() string // 工具名 + Description() string // 给 LLM 看的描述 + Parameters() map[string]interface{} // JSON Schema 参数定义 + RiskLevel() RiskLevel // 静态风险等级 + Execute(ctx context.Context, args map[string]interface{}) (*Result, error) +} + +// DynamicRiskTool 动态风险评估接口 // Logan.Li +// 工具可额外实现此接口,依据运行时参数动态计算风险(如 ssh_execute 看具体命令)。 +// 安全控制器 Check 时优先调用此接口。 +type DynamicRiskTool interface { + Tool + RiskLevelForArgs(args map[string]interface{}) RiskLevel +} + +// Registry 工具注册中心(线程安全) // Logan.Li +type Registry struct { + tools map[string]Tool + mu sync.RWMutex +} + +// NewRegistry 创建空注册中心 // Logan.Li +func NewRegistry() *Registry { + return &Registry{tools: make(map[string]Tool)} +} + +// Register 注册工具 // Logan.Li +func (r *Registry) Register(tool Tool) { + r.mu.Lock() + defer r.mu.Unlock() + r.tools[tool.Name()] = tool +} + +// Get 按名获取工具 // Logan.Li +func (r *Registry) Get(name string) (Tool, error) { + r.mu.RLock() + defer r.mu.RUnlock() + t, ok := r.tools[name] + if !ok { + return nil, fmt.Errorf("工具未找到: %s", name) + } + return t, nil +} + +// List 列出所有工具 // Logan.Li +func (r *Registry) List() []Tool { + r.mu.RLock() + defer r.mu.RUnlock() + list := make([]Tool, 0, len(r.tools)) + for _, t := range r.tools { + list = append(list, t) + } + return list +} + +// ToLLMTools 转换为 LLM function calling 格式 // Logan.Li +func (r *Registry) ToLLMTools() []llm.Tool { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]llm.Tool, 0, len(r.tools)) + for _, t := range r.tools { + out = append(out, llm.Tool{ + Type: "function", + Function: llm.ToolFunction{ + Name: t.Name(), + Description: t.Description(), + Parameters: t.Parameters(), + }, + }) + } + return out +} + +// ---- 参数解析辅助 ---- // Logan.Li + +// StringParam 取字符串参数 // Logan.Li +func StringParam(args map[string]interface{}, key string) string { + if v, ok := args[key]; ok { + if s, ok := v.(string); ok { + return s + } + } + return "" +} + +// IntParam 取整型参数(兼容 int/float64/int64) // Logan.Li +func IntParam(args map[string]interface{}, key string) int { + if v, ok := args[key]; ok { + switch val := v.(type) { + case int: + return val + case float64: + return int(val) + case int64: + return int(val) + } + } + return 0 +} diff --git a/backend/pkg/agentcli/tools/risk.go b/backend/pkg/agentcli/tools/risk.go new file mode 100644 index 0000000..1440792 --- /dev/null +++ b/backend/pkg/agentcli/tools/risk.go @@ -0,0 +1,170 @@ +// risk.go - 命令风险评估(黑名单 + 动态分级) // Logan.Li +// 移植自 opsxcli internal/agent/safety/risk.go,置于 tools 包避免与 safety 循环依赖。 +// 供 ssh_execute 的 DynamicRiskTool.RiskLevelForArgs 调用,决定是否触发用户确认。 +package tools + +import "strings" + +// dangerousCommands 危险命令黑名单(系统损坏/数据丢失/服务中断) // Logan.Li +var dangerousCommands = []string{ + "rm -rf /", "rm -rf /*", "rm -rf /bin", "rm -rf /boot", "rm -rf /etc", + "rm -rf /home", "rm -rf /lib", "rm -rf /lib64", "rm -rf /proc", "rm -rf /root", + "rm -rf /sbin", "rm -rf /sys", "rm -rf /usr", "rm -rf /var", + "mkfs", "mkfs.ext", "mkfs.xfs", "mkfs.btrfs", "mkfs.vfat", + "dd if=/dev/zero", "dd if=/dev/random", "dd if=/dev/urandom", + ":(){ :|:& };:", ":() { : | : & }; :", + "> /dev/sda", "> /dev/sdb", "> /dev/sdc", "> /dev/hda", + "mv / /dev/null", "mv /* /dev/null", + "chmod -R 000 /", "chmod -R 777 /", + "systemctl stop ssh", "systemctl disable ssh", "kill -9 1", + "iptables -F", "iptables --flush", +} + +// readOnlyCommands 只读命令(安全) // Logan.Li +var readOnlyCommands = []string{ + "cat", "grep", "egrep", "fgrep", "awk", "sed", "head", "tail", "less", "more", + "sort", "uniq", "wc", "ps", "top", "df", "du", "free", "uptime", "uname", + "hostname", "ifconfig", "ip addr", "ip link", "ip route", "netstat", "ss", + "ls", "ll", "pwd", "who", "w", "last", "vmstat", "iostat", "mpstat", "sar", + "lsof", "find", "which", "whereis", "file", "stat", "date", "echo", "id", + "whoami", "groups", "getent", "dmesg", "systemctl status", "systemctl list-units", + "lsblk", "blkid", "mount", "journalctl", +} + +// modificationCommands 文件修改命令(低-中风险) // Logan.Li +var modificationCommands = []string{ + "cp", "mv", "mkdir", "rmdir", "touch", "chmod", "chown", "chgrp", "ln", + "tar", "gzip", "gunzip", "zip", "unzip", "rsync", "tee", +} + +// highRiskCommands 高风险命令(服务控制/删除/重启) // Logan.Li +var highRiskCommands = []string{ + "rm", "dd", "shutdown", "reboot", "halt", "poweroff", "init", + "systemctl restart", "systemctl stop", "systemctl start", + "kill", "killall", "pkill", "userdel", "groupdel", "fdisk", "parted", "mkfs", +} + +// IsDangerousCommand 检查命令是否命中黑名单 // Logan.Li +// 返回 (是否危险, 命中的模式)。 +func IsDangerousCommand(command string) (bool, string) { + trimmed := strings.TrimSpace(command) + if trimmed == "" { + return false, "" + } + lower := strings.ToLower(trimmed) + for _, d := range dangerousCommands { + if strings.Contains(lower, strings.ToLower(d)) { + return true, d + } + } + // rm -rf + 根目录路径特殊检测 + if strings.HasPrefix(lower, "rm") && strings.Contains(lower, "-rf") && containsRootPath(lower) { + return true, "rm -rf 包含根目录路径" + } + return false, "" +} + +// containsRootPath 检测是否含系统根目录路径 // Logan.Li +func containsRootPath(cmd string) bool { + paths := []string{" /boot", " /etc", " /usr", " /var", " /lib", " /lib64", + " /sbin", " /bin", " /opt", " /home", " /root", " /tmp", " /var/log"} + for _, p := range paths { + if strings.Contains(cmd, p) { + return true + } + } + return false +} + +// AssessBashRisk 动态评估命令风险等级 // Logan.Li +// 黑名单→Critical;纯只读→Safe;组合命令取最高;sudo 提升。 +func AssessBashRisk(command string) RiskLevel { + trimmed := strings.TrimSpace(command) + if trimmed == "" { + return RiskSafe + } + lower := strings.ToLower(trimmed) + + if isDangerous, _ := IsDangerousCommand(command); isDangerous { + return RiskCritical + } + if isReadOnlyCommand(lower) { + return RiskSafe + } + if strings.Contains(lower, "&&") || strings.Contains(lower, "||") || + strings.Contains(lower, "|") || strings.Contains(lower, ";") { + return assessCompoundCommand(lower) + } + isSudo := strings.HasPrefix(lower, "sudo ") + return assessSingleCommand(strings.TrimPrefix(lower, "sudo "), isSudo) +} + +// isReadOnlyCommand 是否纯只读命令 // Logan.Li +func isReadOnlyCommand(cmd string) bool { + cmd = strings.TrimPrefix(cmd, "sudo ") + cmd = strings.TrimSpace(cmd) + for _, ro := range readOnlyCommands { + if strings.HasPrefix(cmd, strings.ToLower(ro)) { + return true + } + } + return false +} + +// assessCompoundCommand 组合命令取子命令最高风险 // Logan.Li +func assessCompoundCommand(cmd string) RiskLevel { + maxRisk := RiskSafe + parts := []string{cmd} + for _, sep := range []string{"&&", "||", "|", ";"} { + var next []string + for _, p := range parts { + for _, sp := range strings.Split(p, sep) { + sp = strings.TrimSpace(sp) + if sp != "" { + next = append(next, sp) + } + } + } + parts = next + } + for _, p := range parts { + if r := assessSingleCommand(strings.TrimPrefix(p, "sudo "), false); r > maxRisk { + maxRisk = r + } + } + if strings.HasPrefix(cmd, "sudo ") && maxRisk < RiskCritical { + maxRisk++ + } + return maxRisk +} + +// assessSingleCommand 单命令风险(按前缀分类) // Logan.Li +func assessSingleCommand(cmd string, isSudo bool) RiskLevel { + cmd = strings.TrimSpace(cmd) + fields := strings.Fields(cmd) + if len(fields) == 0 { + return RiskSafe + } + main := strings.ToLower(fields[0]) + + for _, h := range highRiskCommands { + if main == strings.ToLower(h) || strings.HasPrefix(main, strings.ToLower(h)) { + if isSudo { + return RiskHigh + } + return RiskMedium + } + } + for _, m := range modificationCommands { + if main == strings.ToLower(m) { + if isSudo { + return RiskMedium + } + return RiskLow + } + } + if isSudo { + return RiskMedium + } + return RiskLow +} diff --git a/backend/pkg/agentcli/tools/ssh_execute.go b/backend/pkg/agentcli/tools/ssh_execute.go new file mode 100644 index 0000000..3970f13 --- /dev/null +++ b/backend/pkg/agentcli/tools/ssh_execute.go @@ -0,0 +1,100 @@ +// ssh_execute.go - 远程命令执行工具 // Logan.Li +// 复用 aiops 既有 pkg/remoteexec(Go 原生 SSH),凭据由 controller 从 model.Server 构建后注入。 +// 实现 DynamicRiskTool:依据具体命令动态评估风险,决定是否触发用户确认。 +package tools + +import ( + "context" + "fmt" + "strings" + "time" + + "ezops/pkg/remoteexec" +) + +// SSHExecuteTool 远程命令执行工具 // Logan.Li +type SSHExecuteTool struct { + cfg remoteexec.SSHConfig // 目标主机连接配置(含凭据) + timeout time.Duration // 默认命令超时 +} + +// NewSSHExecuteTool 创建远程执行工具 // Logan.Li +func NewSSHExecuteTool(cfg remoteexec.SSHConfig, timeout time.Duration) *SSHExecuteTool { + if timeout <= 0 { + timeout = 60 * time.Second + } + return &SSHExecuteTool{cfg: cfg, timeout: timeout} +} + +// Name 工具名 // Logan.Li +func (t *SSHExecuteTool) Name() string { return "ssh_execute" } + +// Description 给 LLM 的工具说明 // Logan.Li +func (t *SSHExecuteTool) Description() string { + host := t.cfg.Host + if host == "" { + host = "目标主机" + } + return fmt.Sprintf( + "在远程服务器 %s(用户 %s)上执行 shell 命令并返回合并的 stdout+stderr 输出与退出码。"+ + "用于排障(查看日志/进程/磁盘/网络)、安装与配置软件、重启服务等。"+ + "高危命令(rm -rf /、mkfs、reboot 等)需用户确认。", host, t.cfg.User) +} + +// Parameters JSON Schema 参数定义 // Logan.Li +func (t *SSHExecuteTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "command": map[string]interface{}{ + "type": "string", + "description": "要执行的 shell 命令,例如 df -h、systemctl status nginx、tail -n 100 /var/log/messages", + }, + "timeout": map[string]interface{}{ + "type": "integer", + "description": "命令超时秒数(默认 60)", + }, + }, + "required": []string{"command"}, + } +} + +// RiskLevel 静态风险等级(高危) // Logan.Li +func (t *SSHExecuteTool) RiskLevel() RiskLevel { return RiskHigh } + +// RiskLevelForArgs 依据命令动态评估风险 // Logan.Li +func (t *SSHExecuteTool) RiskLevelForArgs(args map[string]interface{}) RiskLevel { + return AssessBashRisk(StringParam(args, "command")) +} + +// Execute 执行远程命令 // Logan.Li +func (t *SSHExecuteTool) Execute(ctx context.Context, args map[string]interface{}) (*Result, error) { + command := strings.TrimSpace(StringParam(args, "command")) + if command == "" { + return &Result{Success: false, Error: "参数 command 不能为空"}, nil + } + + timeout := t.timeout + if sec := IntParam(args, "timeout"); sec > 0 { + timeout = time.Duration(sec) * time.Second + } + + res, err := remoteexec.Exec(t.cfg, command, timeout) + if err != nil { + // 连接级失败(无法 SSH) + return &Result{Success: false, Error: fmt.Sprintf("SSH 连接失败: %v", err)}, nil + } + + // 附带退出码,便于 agent 判断命令是否成功(排障关键信号) + output := res.Output + if !strings.HasSuffix(output, "\n") && output != "" { + output += "\n" + } + output += fmt.Sprintf("[exit_code=%d]", res.ExitCode) + + return &Result{ + Success: res.Success, // exit_code==0 + Output: output, + Error: res.Error, + }, nil +} diff --git a/backend/pkg/database/database.go b/backend/pkg/database/database.go index 3469f4f..8fb1760 100644 --- a/backend/pkg/database/database.go +++ b/backend/pkg/database/database.go @@ -154,6 +154,8 @@ func AutoMigrate(db *gorm.DB) error { &model.HealthProbe{}, // 健康探针配置 // Logan.Li &model.ApprovalWorkflow{}, // 审批流程定义 // Logan.Li &model.ApprovalWorkflowStep{}, // 审批流程步骤 // Logan.Li + &model.AgentCLISession{}, // Agent CLI 任务会话 // Logan.Li + &model.AgentCLIMessage{}, // Agent CLI 推理消息 // Logan.Li } var lastErr error diff --git a/backend/router/routes.go b/backend/router/routes.go index 558db0e..1d0a75d 100644 --- a/backend/router/routes.go +++ b/backend/router/routes.go @@ -887,6 +887,16 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c } // 指标浏览器 — 监控指标元数据 + 时序查询 // Logan.Li + // Agent CLI — AI 运维 Agent(WebSocket 推流 + 会话历史回放) // Logan.Li + agentCLIHandler := controller.NewAgentCLIHandler(db) + r.GET("/ws/agent-cli", middleware.JWTAuth(&cfg.JWT, db), agentCLIHandler.Run) + agentCLIGroup := authorized.Group("/aiops/agent-cli") + { + agentCLIGroup.GET("/sessions", agentCLIHandler.ListSessions) + agentCLIGroup.GET("/sessions/:id", agentCLIHandler.GetSession) + agentCLIGroup.DELETE("/sessions/:id", agentCLIHandler.DeleteSession) + } + metricsHandler := controller.NewMetricsHandler(db) metricsGroup := authorized.Group("/metrics") { diff --git a/backend/seed/seed.go b/backend/seed/seed.go index 77e4de3..50c774f 100644 --- a/backend/seed/seed.go +++ b/backend/seed/seed.go @@ -172,6 +172,8 @@ func SeedData(db *gorm.DB) { {Name: "智能助手", Code: "aiops:assistant", Type: "menu", Sort: 60, Status: 1}, {Name: "告警分析", Code: "aiops:analysis", Type: "menu", Sort: 61, Status: 1}, {Name: "容量预测", Code: "aiops:forecast", Type: "menu", Sort: 62, Status: 1}, + {Name: "Agent CLI", Code: "aiops:agent-cli", Type: "menu", Sort: 63, Status: 1}, + {Name: "大模型设置", Code: "aiops:llm-config", Type: "menu", Sort: 64, Status: 1}, // 资源申请 {Name: "资源申请", Code: "approval:apply", Type: "menu", Sort: 70, Status: 1}, {Name: "审批管理", Code: "approval:manage", Type: "button", Sort: 71, Status: 1}, diff --git a/docs/agent/00-README.md b/docs/agent/00-README.md new file mode 100644 index 0000000..65890f4 --- /dev/null +++ b/docs/agent/00-README.md @@ -0,0 +1,74 @@ +# Agent CLI 设计文档(aiops) + +> 本文由 opsxcli 同名设计文档集适配改写,结合 aiops Agent CLI 的 Web 实现(`backend/pkg/agentcli/`)。 + +本系列文档是 **aiops Agent CLI**(Web 版 AI 运维 Agent)的设计文档集。Agent CLI 把 opsxcli 项目的 AI Agent 能力(ReAct 推理、工具调用、自进化、安全审批)移植到 aiops 的 Gin 后端,剥离 CLI/TUI 外壳,改造成一个由 WebSocket 驱动、与 React 前端对话的「类似 Claude Code / Codex」的运维 Agent 库。 + +一句话定位:**用户在 Agent CLI 页面选择目标主机,用自然语言描述任务,后端 ReAct 引擎自主通过 SSH 在远程服务器上排障、修复,并通过 WebSocket 把思考、命令、输出实时回传给前端。** + +--- + +## 文档目录 + +| 编号 | 文档 | 一句话简介 | +|------|------|-----------| +| 01 | [项目概述与设计哲学](01-项目概述与设计哲学.md) | Agent CLI 的定位、四大设计哲学、与 opsxcli 的关系 | +| 02 | [整体架构设计](02-整体架构设计.md) | 引擎五层结构(controller / model / pkg / frontend / evolver)与执行流程 | +| 03 | [Harness 工程化与反馈回路](03-Harness工程化与反馈回路.md) | Harness Engineering 理论在 aiops Agent CLI 中的落地(前馈 + 反馈 + Steering) | +| 04 | [插件架构设计](04-插件架构设计.md) | Tool 接口 + Registry 工具注册中心,Web 版精简为 2 个工具 | +| 05 | [Builtin 内置工具](05-Builtin内置命令.md) | ssh_execute 与 analyze_output 两个内置工具的设计 | +| 06 | [Agent 核心引擎设计](06-Agent核心引擎设计.md) | ReAct 16 轮主循环、自适应检查点、循环检测、Evolver 集成 | +| 07 | [Prompt 构建与记忆注入](07-Prompt构建与记忆注入.md) | 五层 System Prompt 与动态记忆注入 | +| 08 | [Evolver 自进化引擎](08-Evolver自进化引擎.md) | 四层记忆 + 精简进化循环(OBSERVE→…→PREDICT) | +| 09 | [安全系统与工具注册](09-安全系统与工具注册.md) | 三级安全模式、五级风险、黑名单、WebSocket 确认 | +| 10 | [会话管理](10-会话管理.md) | GORM 数据库表持久化会话与消息(多用户隔离) | + +--- + +## opsxcli → aiops 映射总览 + +| opsxcli 模块 | opsxcli 路径 | aiops 对应 | 处理方式 | +|--------------|-------------|-----------|----------| +| Agent ReAct 核心 | `internal/agent/core/` | `backend/pkg/agentcli/core/agent.go` | **适配**:剥离 TUI,改 EventCallback 推 WebSocket;保留 16 轮循环 / 检查点 / 循环检测 | +| 工具注册中心 | `internal/agent/tools/registry.go` | `backend/pkg/agentcli/tools/registry.go` | **原样移植**:Tool 接口(5 方法)+ DynamicRiskTool + Registry(线程安全) | +| 内置工具集 | `local_bash / ssh_execute / scp_transfer / file_read / file_search / analyze_output`(6 个) | `ssh_execute` + `analyze_output`(2 个) | **精简剥离**:去掉 local_bash(不在后端服务器本地执行)、scp、file_read、file_search | +| 安全系统 | `internal/agent/safety/` | `backend/pkg/agentcli/safety/safety.go` + `tools/risk.go` | **适配**:三级模式 / 五级风险 / 黑名单保留;确认机制由 CLI 终端交互改为 WebSocket confirm 事件 + 前端弹框 | +| Prompt 构建 | `internal/agent/prompt/` | `backend/pkg/agentcli/prompt/prompt.go` | **适配**:保留五层架构,角色改写为「aiops Agent CLI 智能运维 Agent」 | +| 自进化引擎 | `internal/agent/evolver/`(13 步 + 3300 行) | `backend/pkg/agentcli/evolver/engine.go` + `memory.go`(~400 行) | **精简适配**:保留四层记忆 + 进化循环思想;步骤从 13 步精简为核心 6 步(OBSERVE→CLASSIFY→LEARN→STORE→MEMORIZE→PREDICT) | +| LLM 客户端 | `internal/llm/`(多 Provider 配置向导 + claude.go/gemini.go 等 15 Provider) | `backend/pkg/agentcli/llm/openai.go` + `types.go` | **精简剥离**:剥离 wizard 配置向导和多 Provider,统一走 OpenAI 兼容接口;配置来自 DB 表 `ai_llm_configs`(model.LLMConfig) | +| 会话管理 | `internal/agent/session/`(JSONL 文件存储) | `backend/model/agent_cli.go` + `controller/agent_cli.go`(GORM DB) | **改造**:多用户 Web 场景下从本地文件改为数据库表 `agent_cli_sessions` / `agent_cli_messages`,按 user_id 隔离 | +| 记忆存储 | `~/.opsxcli/agent/*.jsonl` | `data/agentcli/*.jsonl`(工作目录) | **适配**:从用户主目录改为应用工作目录 | +| LLM 配置 | `~/.opsxcli/providers/*.json` | DB 表 `ai_llm_configs` + 「大模型设置」页面 | **改造**:从本地文件改为 DB 表 + Web 配置页 | +| CLI 命令层 | `cmd/`(Cobra) | `backend/controller/agent_cli.go`(Gin WebSocket handler) | **改造**:从 Cobra 命令改为 WebSocket 控制器 | +| TUI 终端界面 | `internal/agent/tui/`(Bubble Tea) | `frontend/src/components/aiops/AgentCLI.tsx`(React) | **改造**:从 Bubble Tea TUI 改为 React 对话流组件 | +| 远程执行 | `internal/agent/tools/` 内 SSH 实现 | `pkg/remoteexec/`(Go-native SSH,aiops 既有) | **复用**:直接复用 aiops 已有的远程执行包 | + +--- + +## 移植决策 + +### 剥离了什么 + +1. **CLI 外壳**:去掉 Cobra 命令定义、参数解析、帮助文本 —— 由 WebSocket 控制器替代。 +2. **TUI 终端界面**:去掉 Bubble Tea + 全套终端渲染 —— 由 React 对话流组件替代。 +3. **LLM 多 Provider 配置向导**:去掉 `wizard.go` 交互式向导、`claude.go` / `gemini.go` 等专有 Provider —— 统一走 OpenAI 兼容 `/chat/completions`,覆盖 deepseek / qwen / moonshot / ollama / openai / custom。 +4. **本地执行类工具**:去掉 `local_bash`(Web 后端不在服务器本地执行命令)、`scp_transfer`、`file_read`、`file_search` —— Web 版聚焦远程运维。 +5. **Evolver 复杂分析**:去掉 opsxcli 13 步循环中的反射评分、经验整合去重、Skill 自动提炼等重型子流程 —— 精简为核心学习链路。 + +### 适配了什么 + +1. **会话持久化**:从本地 JSONL 文件改为 GORM 数据库表,支持多用户并发与按用户隔离。 +2. **记忆路径**:从 `~/.opsxcli/agent/` 改为应用工作目录 `data/agentcli/`,便于容器化部署。 +3. **LLM 配置来源**:从 `~/.opsxcli/providers/*.json` 改为 DB 表 `ai_llm_configs`,通过「大模型设置」页面配置。 +4. **确认机制**:从 CLI 终端 `bufio.Reader` 改为 WebSocket `confirm` 事件 + 前端 `useConfirm()` 弹框。 +5. **SSH 凭据**:从 `~/.ssh/config` 解析改为关联 `model.Server` 凭据(凭证系统 v2),由 controller 注入引擎。 +6. **角色定位**:从「opsxcli 智能运维超级助手」改写为「aiops Agent CLI 智能运维 Agent(Web 版,类似 Claude Code)」。 + +### 精简了什么 + +1. **工具集**:6 个 → 2 个(`ssh_execute` + `analyze_output`)。 +2. **Evolver 步骤**:13 步 → 6 步(保留学习与注入能力,剥离重型分析)。 +3. **LLM 层**:15 个 Provider 文件 → 1 个 OpenAI 兼容客户端。 +4. **记忆层级**:保留四层(经验 / 环境 / 事实 / 程序),但程序层仅保留接口位、未实现自动 Skill 提炼。 + +> 作者:Logan.Li diff --git a/docs/agent/01-项目概述与设计哲学.md b/docs/agent/01-项目概述与设计哲学.md new file mode 100644 index 0000000..34ed0ca --- /dev/null +++ b/docs/agent/01-项目概述与设计哲学.md @@ -0,0 +1,230 @@ +# 01 - 项目概述与设计哲学 + +> 本文由 opsxcli 同名设计文档适配改写,结合 aiops Agent CLI 的 Web 实现(`backend/pkg/agentcli/`)。 +> opsxcli 原文档把 Agent 描述为 CLI 工具(`opsxcli agent -q "..."`),aiops 改为 Web 页面 + WebSocket 事件流。涉及 CLI 特性的地方已注明 aiops 的对应实现。 + +> 建议阅读顺序:01 → 02 → 03 → ... → 10 + +## 项目定位 + +**aiops Agent CLI** —— aiops 平台内置的 Web 版智能运维 Agent。 + +类似 Claude Code / Codex,但运行在浏览器里:用户在「Agent CLI」页面选择一台目标主机,用自然语言描述运维任务(如「检查磁盘空间」「nginx 启动失败的原因」),后端 ReAct 引擎自主通过 SSH 在远程服务器上排查、修复,并通过 WebSocket 把每一步思考、执行的命令、输出与最终结果实时回传给前端。 + +这是把 opsxcli(基于 Cobra 的 CLI 运维 Agent)的 AI 能力,剥离 CLI/TUI 外壳、适配为 aiops 的 Gin 后端库后的产物。 + +## 设计目标 + +| 维度 | 目标 | +|------|------| +| 形态 | Web 对话流(WebSocket 双向通信),不是命令行 | +| 部署 | 嵌入 aiops 后端二进制(go:embed),无需独立服务 | +| 安全 | 默认 balanced;高危命令前端确认;危险命令硬拒 | +| 智能 | 16 轮 ReAct 主循环 + 自进化记忆注入 | +| 隔离 | 多用户并发,会话/消息按 user_id 隔离,持久化到 DB | + +## 五层引擎结构 + +aiops Agent CLI 不是单一包,而是分散在 aiops 的标准 Gin 分层中: + +``` +┌─────────────────────────────────────────────────────┐ +│ frontend/ (React 对话流 UI) │ +│ components/aiops/AgentCLI.tsx │ +│ - WebSocket 客户端,渲染 thought/tool_call/ │ +│ tool_result/done/error 事件流 │ +│ - 高危命令 useConfirm() 弹框确认 │ +├─────────────────────────────────────────────────────┤ +│ controller/ (HTTP/WS handler) │ +│ controller/agent_cli.go │ +│ - WebSocket 入口 /ws/agent-cli?id= │ +│ - 构建 Engine、注入 EventCallback + ConfirmFn │ +│ - 事件落库 + 推 WS │ +├─────────────────────────────────────────────────────┤ +│ model/ (GORM 模型) │ +│ model/agent_cli.go │ +│ - AgentCLISession / AgentCLIMessage │ +│ - LLMConfig (ai_llm_configs) │ +├─────────────────────────────────────────────────────┤ +│ pkg/agentcli/ (引擎库,零业务依赖) │ +│ ├── agentcli.go Engine 顶层入口 │ +│ ├── core/ ReAct 主循环 │ +│ ├── tools/ 工具注册 + ssh_execute 等 │ +│ ├── safety/ 安全控制器 │ +│ ├── prompt/ 五层 System Prompt │ +│ ├── evolver/ 自进化引擎(四层记忆) │ +│ └── llm/ OpenAI 兼容客户端 │ +├─────────────────────────────────────────────────────┤ +│ pkg/remoteexec/ (复用 aiops 既有 Go-native SSH) │ +└─────────────────────────────────────────────────────┘ +``` + +**关键设计原则**:`pkg/agentcli/` 零业务依赖 —— 不 import `model` / `controller`,所有持久化与 WebSocket 由 controller 层负责。引擎只关心「思考-行动-观察」循环本身。 + +## Agent 引擎子模块全景 + +`backend/pkg/agentcli/` 包含 7 个子包: + +``` +backend/pkg/agentcli/ +├── agentcli.go ── Engine 顶层入口:组装 LLM + 工具 + 安全 + Evolver + ReAct 核心 +├── core/ ── ReAct 核心引擎:16 轮主循环 + 自适应检查点 + 循环检测 +├── prompt/ ── Prompt 构建:五层 System Prompt + 动态记忆注入 +├── evolver/ ── 自进化引擎:四层记忆 + 精简进化循环 +├── safety/ ── 安全系统:三级模式 + 阈值确认(ConfirmFn 回调) +├── tools/ ── 工具注册中心 + 2 个内置工具 + 风险评估 +└── llm/ ── LLM 抽象层(OpenAI 兼容,剥离多 Provider) +``` + +> 与 opsxcli 的差异:opsxcli 有 `tui/` 子包(Bubble Tea 终端界面),aiops 把它替换为前端 React 组件 `AgentCLI.tsx`,由 controller 序列化事件推 WS。 + +## 四大设计哲学 + +### 1. 安全第一 (Safety First) + +> 运维工具的底线是「不搞砸」。 + +- 所有工具执行前必须通过安全评估(`safety.Controller.Check`) +- 三级安全模式:`strict` / `balanced`(默认)/ `permissive` +- 5 级风险等级:Safe → Low → Medium → High → Critical +- 危险命令黑名单硬拒(不可确认放行):`rm -rf /`、`mkfs`、`dd if=/dev/zero`、fork bomb 等 +- 高危命令经 WebSocket `confirm` 事件 + 前端 `useConfirm()` 弹框由用户确认 +- 会话与每条消息落库,事后可审计(`agent_cli_messages` 表) + +### 2. 渐进式复杂度 (Progressive Complexity) + +> 简单任务零配置,复杂任务有能力。 + +- 默认配置即可运行(`core.DefaultConfig()`) +- LLM 配置通过「大模型设置」页面填一次即生效(DB 表 `ai_llm_configs`) +- 引擎初始化时优雅降级:Evolver 失败设为 `nil`,不影响主 ReAct 循环 +- 安全模式按需升级(balanced 默认 → strict 高敏生产环境) + +### 3. 工具化思维 (Tool-Oriented) + +> 万物皆 Tool,一切可注册。 + +- 统一的 `Tool` 接口(5 方法):`Name` / `Description` / `Parameters` / `RiskLevel` / `Execute` +- `DynamicRiskTool` 扩展接口:依据运行时参数动态评估风险(如 `ssh_execute` 看具体命令) +- `Registry` 线程安全注册中心 +- 实现接口即可扩展,无需修改核心代码 +- **Web 版内置工具精简为 2 个**(剥离 opsxcli 的 local_bash / scp / file_read / file_search): + - `ssh_execute` —— 在目标主机远程执行命令(复用 `pkg/remoteexec`) + - `analyze_output` —— 对冗长输出做摘要/错误提取,避免 token 爆炸 + +### 4. 自我进化 (Self-Evolution) + +> 用得越多,越懂你。 + +- Evolver 引擎在任务完成后异步学习(30s 超时 + panic 恢复,绝不阻塞主循环) +- 四层记忆(`data/agentcli/`): + - **经验记忆**(`experience.jsonl`)—— 任务类型 → 工具序列 + 提示,按成功率优先注入 + - **环境记忆**(`environment.json`)—— 已知主机列表 + - **事实记忆**(`factual.jsonl`)—— LLM 提炼的持久事实 + - **程序记忆**(`procedural.jsonl`)—— 程序性知识(接口位,未实现自动 Skill 提炼) +- 经验按任务类型分类(磁盘空间 / 网络 / 服务管理 / 软件安装 等 11 类) +- 下次任务开始时,Evolver 通过 `GetContextForPrompt()` 把相关历史经验注入 System Prompt + +## Agent 核心工作流 + +``` +用户在 Agent CLI 页面选择主机,输入 "内存使用率" + │ + ▼ +┌──────────────────────┐ +│ WebSocket 入口 │ ← controller/agent_cli.go: Run() +│ /ws/agent-cli?id=… │ 校验 JWT、加载 model.Server、解析 SSH 凭据 +└────────┬─────────────┘ + │ + ▼ +┌──────────────────────┐ +│ Engine 组装 │ ← pkg/agentcli/agentcli.go: NewEngine() +│ LLM(OpenAI兼容) │ 注入 EventCallback + ConfirmFn +│ + tools(ssh/analyze)│ +│ + safety + Evolver │ +└────────┬─────────────┘ + │ + ▼ +┌──────────────────────┐ +│ Prompt Builder │ ← prompt/prompt.go: BuildSystemPrompt() +│ 五层 System Prompt │ Layer 1 角色 / 2 安全 / 3 工具 / 4 工作方式 / 5 记忆 +│ + Evolver 记忆注入 │ +└────────┬─────────────┘ + │ + ▼ +┌──────────────────────┐ ┌──────────────┐ +│ LLM 推理 │────▶│ 工具调用 │ +│ (ReAct 16 轮循环) │◀────│ ssh_execute │ +└────────┬─────────────┘ └──────┬───────┘ + │ │ + │ ┌──────────────────────┘ + │ │ 每步都 emit Event + │ ▼ +│ 自适应检查点 │ ┌──────────────┐ +│ (Round 4/8/12) │ │ 安全检查 │ +│ 循环检测(>3次拒绝) │ │ (RiskLevel) │ +│ 连续失败追踪 │ │ 高危→confirm│ +└────────┬─────────────┘ └──────────────┘ + │ EventCallback → controller + │ ├─ 推 WS: thought/tool_call/tool_result/done + │ └─ 落库: agent_cli_messages + ▼ (16 轮迭代或 LLM 不再返回 tool_calls) +┌──────────────────────┐ +│ Evolver 学习 │ ← 后台 goroutine,不影响响应速度 +│ (经验 + 主机 + 事实) │ +└──────────────────────┘ +``` + +## LLM 客户端策略 + +> opsxcli 有 15 个 Provider(注册制工厂 + `claude.go` / `gemini.go` 等专有实现 + 配置向导 `wizard.go`)。 +> aiops 精简为单一 OpenAI 兼容客户端,覆盖所有 OpenAI 协议兼容后端。 + +```go +// backend/pkg/agentcli/llm/openai.go +// 唯一客户端实现:走标准 /chat/completions + Bearer 鉴权 +// 支持后端:openai / deepseek / qwen / moonshot / ollama / custom +func NewClient(cfg Config) Client +``` + +配置不再来自 `~/.opsxcli/providers/*.json`,而是 DB 表 `ai_llm_configs`(`model.LLMConfig`),由 controller 在握手阶段加载并注入 Engine。 + +## 文档阅读指南 + +| 编号 | 文档 | 核心内容 | +|------|------|----------| +| 01 | 本文档 | 项目概述、设计哲学、引擎全景 | +| 02 | [整体架构设计](02-整体架构设计.md) | 五层结构、执行流程、依赖关系 | +| 03 | [Harness 工程化与反馈回路](03-Harness工程化与反馈回路.md) | Harness 理论、前馈/反馈、Steering Loop | +| 04 | [插件架构设计](04-插件架构设计.md) | Tool 接口、Registry、扩展规范 | +| 05 | [Builtin 内置工具](05-Builtin内置命令.md) | ssh_execute 与 analyze_output 设计 | +| 06 | [Agent 核心引擎设计](06-Agent核心引擎设计.md) | ReAct 主循环、自适应检查点 | +| 07 | [Prompt 构建与记忆注入](07-Prompt构建与记忆注入.md) | 五层 Prompt、动态记忆层 | +| 08 | [Evolver 自进化引擎](08-Evolver自进化引擎.md) | 四层记忆、精简进化循环 | +| 09 | [安全系统与工具注册](09-安全系统与工具注册.md) | 风险评估、安全模式、Tool 接口 | +| 10 | [会话管理](10-会话管理.md) | GORM 持久化、按用户隔离 | + +> 注:opsxcli 还有第 11 篇「TUI 终端界面设计」,aiops 已剥离 Bubble Tea TUI,由 React 组件 `AgentCLI.tsx` 替代,故本系列不含该篇。 + +## 快速开始 + +```bash +# 1. 在「大模型设置」页面配置 LLM(DB 表 ai_llm_configs,Enabled=true) +# 2. 在「服务器管理」确保目标主机凭据(凭证系统 v2)已配置 +# 3. 进入「Agent CLI」页面,选择主机 → 输入任务 → 观察 WebSocket 推送的事件流 +``` + +代码侧调用示例(controller): + +```go +engine, _ := agentcli.NewEngine(agentcli.EngineConfig{ + LLM: llm.Config{Endpoint: ..., APIKey: ..., Model: ...}, + SSH: sshCfg, // 来自 model.Server 凭据 + SafetyMode: safety.ModeBalanced, +}) +engine.SetCallback(onEvent) // 推 WS + 落库 +engine.SetConfirmFn(confirmFn) // 高危确认 +output, _ := engine.Run(ctx, query, serverInfo) +``` + +> 作者:Logan.Li diff --git a/docs/agent/02-整体架构设计.md b/docs/agent/02-整体架构设计.md new file mode 100644 index 0000000..413434e --- /dev/null +++ b/docs/agent/02-整体架构设计.md @@ -0,0 +1,303 @@ +# 02 - 整体架构设计 + +> 本文由 opsxcli 同名设计文档适配改写,结合 aiops Agent CLI 的 Web 实现(`backend/pkg/agentcli/`)。 +> opsxcli 的「cmd/ + plugins/ + internal/」CLI 三层在 aiops 中改造为「controller/(HTTP/WS handler)+ model/(GORM)+ pkg/agentcli/(引擎库)+ frontend/(React UI)」四层。 + +## 项目结构(Agent CLI 相关部分) + +``` +aiops/ +├── backend/ +│ ├── controller/ +│ │ └── agent_cli.go # WebSocket 控制器(HTTP/WS handler 层) +│ │ # - /ws/agent-cli?id= 入口 +│ │ # - 构建 Engine、注入回调、事件落库 +│ │ # - ListSessions/GetSession/DeleteSession REST +│ ├── model/ +│ │ ├── agent_cli.go # AgentCLISession / AgentCLIMessage(GORM 模型) +│ │ └── llm.go # LLMConfig(ai_llm_configs 表) +│ ├── pkg/ +│ │ ├── agentcli/ # Agent 引擎库(零业务依赖,核心) +│ │ │ ├── agentcli.go # Engine 顶层入口 +│ │ │ ├── core/ # ReAct 主循环 + EventCallback +│ │ │ ├── tools/ # Registry + ssh_execute + analyze_output + risk +│ │ │ ├── safety/ # 安全控制器(ConfirmFn 注入) +│ │ │ ├── prompt/ # 五层 System Prompt +│ │ │ ├── evolver/ # 四层记忆 + 精简进化循环 +│ │ │ └── llm/ # OpenAI 兼容客户端 + 类型定义 +│ │ └── remoteexec/ # 复用 aiops 既有 Go-native SSH +│ └── router/routes.go # 路由注册:WS + REST 会话管理 +│ +├── frontend/ +│ ├── src/ +│ │ ├── components/aiops/AgentCLI.tsx # React 对话流组件 +│ │ ├── api/agentCli.ts # 会话历史 REST 客户端 +│ │ └── components/ui/confirm-dialog.tsx# useConfirm() 高危确认 +│ └── ... +│ +└── data/agentcli/ # Evolver 记忆目录(运行时生成) + ├── experience.jsonl # 经验记忆 + ├── factual.jsonl # 事实记忆 + ├── procedural.jsonl # 程序记忆(接口位) + └── environment.json # 环境记忆(已知主机) +``` + +## 架构设计原则 + +### 1. 分层 + 依赖注入 + +aiops Agent CLI 的核心约束是「引擎库零业务依赖」: + +- **controller 层** —— 负责协议适配(WebSocket)、凭据构建、事件持久化、用户隔离 +- **model 层** —— GORM 模型(会话、消息、LLM 配置、Server) +- **pkg/agentcli 层** —— 纯算法库,只依赖 `pkg/remoteexec` 和自己的子包,**不 import model/controller** +- **frontend 层** —— React 对话流 UI,纯事件消费 + 控制消息发送 + +依赖通过函数参数注入: + +```go +// controller/agent_cli.go 关键注入点 +engine.SetCallback(ac.onEvent) // EventCallback:推 WS + 落库 +engine.SetConfirmFn(ac.confirmFn) // ConfirmFn:高危确认(阻塞等前端回传) +``` + +引擎因此可被任何调度方复用(不仅是 WebSocket,未来也可被 cron 或审批流调用)。 + +### 2. 工具化设计 + +每个工具都是独立的 struct,实现 `Tool` 接口即可注册: + +``` +pkg/agentcli/tools/ +├── registry.go # Registry(线程安全) + Tool/DynamicRiskTool 接口 + RiskLevel +├── ssh_execute.go # 远程命令执行(DynamicRiskTool) +├── builtin.go # analyze_output(只读 Safe) +└── risk.go # 黑名单 + AssessBashRisk 动态评估 +``` + +> 与 opsxcli 的差异:opsxcli 把风险评估放在 `safety/` 包,aiops 为避免 `tools` ↔ `safety` 循环依赖,把 `risk.go` 置于 `tools` 包,由 `ssh_execute` 直接调用。 + +### 3. 配置集中化(DB 驱动) + +opsxcli 通过 `~/.opsxcli/config.yaml` 和环境变量加载配置;aiops 配置来自 DB: + +| 配置项 | opsxcli | aiops | +|--------|---------|-------| +| LLM 配置 | `~/.opsxcli/providers/*.json` | DB 表 `ai_llm_configs`(model.LLMConfig) | +| LLM 配置入口 | CLI 向导 `wizard.go` | 「大模型设置」Web 页面 | +| SSH 凭据 | `~/.ssh/config` 解析 | model.Server 关联凭证系统 v2 | +| 引擎运行参数 | config.yaml + 环境变量 | `core.DefaultConfig()`(硬编码默认,必要时通过 EngineConfig 覆盖) | +| 记忆目录 | `~/.opsxcli/agent/` | 工作目录 `data/agentcli/`(容器化友好) | + +### 4. 协议适配层(controller) + +controller 是「Web 世界」与「引擎世界」的翻译器: + +| Web 协议 | 引擎调用 | 引擎事件 → Web | +|----------|----------|----------------| +| `WS start {query}` | `engine.Run(ctx, query, serverInfo)` | emit `thought`/`tool_call`/`tool_result`/`progress`/`done`/`error` → 序列化为 `wsEvent` 推 WS | +| `WS confirm/deny {confirm_id}` | 解锁 `ConfirmFn` 的 chan | emit 前由 controller 推 `confirm` 事件 | +| `WS abort` | `cancel()` 取消 ctx | emit `error`(ctx.Err()) | +| `GET /sessions` | — | 直接查 `agent_cli_sessions` 表 | + +--- + +## 核心流程图 + +### WebSocket 握手 + 任务执行完整流程 + +以用户在 Agent CLI 页面输入「检查 nginx 启动失败的原因」为例: + +``` +用户在 Agent CLI 页面选择主机、输入任务 "检查 nginx 启动失败的原因" + │ + ▼ +┌──────────────────────────────────────────────────┐ +│ 1. WebSocket 握手 (controller/agent_cli.go: Run) │ +│ ├─ 校验 JWT (middleware.JWTAuth) │ +│ ├─ 加载 model.Server (按 ?id=) │ +│ ├─ buildSSHConfig: 解析凭证系统 v2 凭据 │ +│ ├─ 加载 model.LLMConfig (Enabled=true) │ +│ └─ upgrader.Upgrade 升级 WS │ +└────────────────────┬─────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────┐ +│ 2. 推送 session 事件(主机上下文) │ +│ wsEvent{type:"session", message:"已连接主机…"}│ +└────────────────────┬─────────────────────────────┘ + │ + ▼ (前端发来 {type:"start", query:"..."}) +┌──────────────────────────────────────────────────┐ +│ 3. startTask │ +│ ├─ DB 创建 AgentCLISession (status=running) │ +│ ├─ DB 存用户消息 (role=user) │ +│ ├─ 构建 Engine (注入 LLM/SSH/SafetyMode) │ +│ ├─ engine.SetCallback(ac.onEvent) │ +│ ├─ engine.SetConfirmFn(ac.confirmFn) │ +│ └─ go engine.Run(ctx, query, serverInfo) │ +└────────────────────┬─────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────┐ +│ 4. Engine.Run → core.Agent.Run(ReAct 循环) │ +│ │ +│ ┌───────────────────────────────────┐ │ +│ │ Prompt 构建(五层) │ │ +│ │ L1 角色 / L2 安全 / L3 工具 │ │ +│ │ L4 工作方式 / L5 Evolver 记忆 │ │ +│ └──────────────┬────────────────────┘ │ +│ ▼ │ +│ ┌───────────────────────────────────┐ │ +│ │ for iter := 0; iter < 16; iter++ { │ │ +│ │ emit progress(round) │ │ +│ │ 自适应检查点(iter 3/7/11) │ │ +│ │ trimMessages(裁剪历史) │ │ +│ │ resp = llm.Complete(...) │ │ +│ │ emit thought(resp.Content) │ │ +│ │ if 无 tool_calls: │ │ +│ │ emit done → triggerEvolve │ │ +│ │ return │ │ +│ │ for tc in resp.ToolCalls: │ │ +│ │ risk = toolRisk(...) │ │ +│ │ emit tool_call(name,args,risk) │ │ +│ │ approved = safetyCtl.Check(..)│ ←──┐ │ +│ │ if !approved: 跳过 │ │ 高危 │ +│ │ result = tool.Execute(ctx,args)│ │ 触发 │ +│ │ emit tool_result(output,...) │ │ confirm │ +│ │ records.append(..) │ │ 事件 │ +│ │ } │ │ │ +│ │ 循环检测 / 连续失败追踪 │ │ │ +│ │ } │ │ │ +│ └────────────────────────────────────┘ │ │ +└──────────────────────────────────────────┘ │ │ + │ │ + ┌───────────────────────────┐ + │ confirmFn (controller) │ + │ 推 wsEvent{type:"confirm"}│ + │ ch := make(chan bool) │ + │ select { │ + │ case approved := <-ch: │ + │ case <-5min超时: │ + │ } │ + └─────────┬─────────────────┘ + │ 前端 useConfirm() 弹框 + │ 发回 {type:"confirm"/"deny"} + ▼ + resolveConfirm(confirmID, approved) + │ ch <- approved + ▼ 解锁 safety.Check +``` + +### 事件落库映射 + +`onEvent` 回调把每个事件同时推送 WS 与落库 `agent_cli_messages`: + +| Event.Type | DB 字段 type | DB 字段 role | 备注 | +|------------|--------------|--------------|------| +| `thought` | thought | assistant | AI 思考文本 | +| `tool_call` | tool_call | tool | 含工具名、参数 JSON、风险等级 | +| `tool_result` | tool_result | tool | 含输出、成败、耗时 | +| `done` | done | assistant | 最终答案 + token 统计 | +| `error` | error | assistant | 错误信息(含达到最大轮次) | +| `progress` | — | — | 太频繁,不入库 | + +### 系统数据流 + +``` +┌────── controller 层(协议适配) ──────┐ +│ agent_cli.go (WS handler) │ +│ ├─ 加载 Server/LLMConfig │ +│ ├─ DB 创建 Session/Message │ +│ └─ 注入 Callback/ConfirmFn │ +└─────────────────┬─────────────────────┘ + │ 依赖注入 + ▼ +┌────── pkg/agentcli 层(引擎库) ──────┐ +│ agentcli.go (Engine) │ +│ ├─ core.Agent (ReAct) │ +│ │ ├─ llm.Client (OpenAI 兼容) │ +│ │ ├─ tools.Registry │ +│ │ │ ├─ ssh_execute │ +│ │ │ └─ analyze_output │ +│ │ ├─ safety.Controller │ +│ │ ├─ prompt.BuildSystemPrompt │ +│ │ └─ evolver.EvolverEngine │ +│ └─ (零 model/controller 依赖) │ +└─────────────────┬─────────────────────┘ + │ 调用 + ▼ +┌────── pkg/remoteexec(Go-native SSH)─┐ +│ Exec(cfg, command, timeout) │ +└───────────────────────────────────────┘ +``` + +## 技术栈 + +| 层 | 技术 | +|----|------| +| 后端框架 | Gin + GORM + gorilla/websocket | +| LLM 协议 | OpenAI 兼容 `/chat/completions` + function calling | +| SSH | aiops 既有 `pkg/remoteexec`(Go-native,无外部依赖) | +| 前端 | React 19 + TypeScript + shadcn/ui(`useConfirm` 弹框) | +| 持久化 | MySQL(主库)/ SQLite(降级) —— 复用 aiops 既有 DB | +| 记忆存储 | 文件 `data/agentcli/*.jsonl`(与 opsxcli 原生一致,零 DB 依赖) | + +> 为什么记忆用文件而会话用 DB?记忆是引擎库内部的产物(学习沉淀),与业务无关;会话需要多用户隔离、列表查询、删除等业务操作,必须走 DB。这是「引擎库零业务依赖」原则的延伸。 + +## 路由注册 + +`backend/router/routes.go`: + +```go +// Agent CLI — AI 运维 Agent(WebSocket 推流 + 会话历史回放) +agentCLIHandler := controller.NewAgentCLIHandler(db) +r.GET("/ws/agent-cli", middleware.JWTAuth(&cfg.JWT, db), agentCLIHandler.Run) + +agentCLIGroup := authorized.Group("/aiops/agent-cli") +{ + agentCLIGroup.GET("/sessions", agentCLIHandler.ListSessions) + agentCLIGroup.GET("/sessions/:id", agentCLIHandler.GetSession) + agentCLIGroup.DELETE("/sessions/:id", agentCLIHandler.DeleteSession) +} +``` + +WebSocket 因无法设自定义 header,JWT 通过 `?token=` query param 由 `JWTAuth` 中间件兼容解析。 + +## 添加新模块示例 + +以「增加一个新工具 `ping_check`」为例: + +1. **实现 Tool 接口**(`pkg/agentcli/tools/ping_check.go`): + +```go +type PingCheckTool struct{ cfg remoteexec.SSHConfig } + +func (t *PingCheckTool) Name() string { return "ping_check" } +func (t *PingCheckTool) Description() string { return "在目标主机 ping 指定地址" } +func (t *PingCheckTool) Parameters() map[string]interface{} { /* JSON Schema */ } +func (t *PingCheckTool) RiskLevel() tools.RiskLevel { return tools.RiskSafe } +func (t *PingCheckTool) Execute(ctx context.Context, args map[string]interface{}) (*tools.Result, error) { + // 调用 remoteexec.Exec 执行 ping +} +``` + +2. **在 Engine 中注册**(`pkg/agentcli/agentcli.go: NewEngine`): + +```go +registry.Register(tools.NewSSHExecuteTool(cfg.SSH, coreCfg.ToolTimeout)) +registry.Register(tools.NewAnalyzeOutputTool()) +registry.Register(tools.NewPingCheckTool(cfg.SSH)) // 新增 +``` + +3. 工具自动出现在 LLM 的 `tools` 参数中(`registry.ToLLMTools()`),Prompt Layer 3 工具描述自动生成(`buildToolDescriptions()`)。无需改 Prompt、无需改 controller、无需改前端。 + +## 设计目标 + +1. **Web 友好**:所有交互经 WebSocket,适配浏览器对话流 +2. **零业务耦合**:引擎库可独立测试与复用 +3. **复用 aiops 既有能力**:LLM 配置、SSH、凭证系统、DB +4. **易于扩展**:Tool 接口 + Registry,实现接口即扩展 +5. **可审计**:每条事件落库,会话历史可回放 + +> 作者:Logan.Li diff --git a/docs/agent/03-Harness工程化与反馈回路.md b/docs/agent/03-Harness工程化与反馈回路.md new file mode 100644 index 0000000..7969d00 --- /dev/null +++ b/docs/agent/03-Harness工程化与反馈回路.md @@ -0,0 +1,276 @@ +# 03 - Harness 工程化与反馈回路 + +> 本文由 opsxcli 同名设计文档适配改写,结合 aiops Agent CLI 的 Web 实现(`backend/pkg/agentcli/`)。 +> Harness Engineering 是与具体语言/形态无关的理论框架,原文的思想性内容(控制论、Steering Loop、四类调节 Harness)几乎完全保留;落地实例部分从「opsxcli CLI + golangci-lint」改写为「aiops 后端库 + WebSocket 反馈回路 + Evolver 自进化」。 + +--- + +## 1. 核心定义:Agent = Model + Harness + +### 1.1 什么是 Harness + +> **Agent = Model + Harness** — LangChain + +Harness 是 Agent 中除 Model 本身之外的一切。在大模型运维 Agent 的有界上下文中,Harness 分为两个层次: + +- **内置 Harness(built-in harness)**:引擎作者构建的部分 —— System Prompt、工具注册机制、ReAct 编排循环 +- **外部 Harness(outer harness)**:业务方为自身场景构建的部分 —— 安全模式、确认机制、记忆注入、审计落库 + +Model 是「大脑」,Harness 是「身体 + 环境 + 工具 + 规则」。大脑决定智能,但身体和环境决定智能能否被正确、可靠、持续地发挥。 + +### 1.2 两个目标 + +> 一个构建良好的 Harness 服务于两个目标: +> 1. **提高智能体首次就做对的概率**(feedforward) +> 2. **提供反馈回路,在问题到达人类前尽可能自我纠正**(feedback) +> — Martin Fowler / Birgitta Böckeler + +这两个目标分别对应**前馈指导**和**反馈传感器**,是 Harness 的双引擎。 + +### 1.3 核心理念 + +> **Humans steer. Agents execute.** — OpenAI + +人类的工作重心从「敲每一条命令」,转向**设计环境、明确意图和构建反馈回路**。在 aiops Agent CLI 中,运维人员的角色从「亲自 SSH 排障」变为「在 Web 上描述任务、审阅高危确认、监督 Agent 执行」。 + +--- + +## 2. 控制论基础:前馈与反馈 + +### 2.1 前馈指导 vs 反馈传感器 + +| 维度 | 前馈指导(Feedforward Guides) | 反馈传感器(Feedback Sensors) | +|------|-------------------------------|-------------------------------| +| **时机** | 智能体行动**之前** | 智能体行动**之后** | +| **目的** | 预期行为并加以引导 | 观察产出并帮助自我纠正 | +| **效果** | 提高首次做对的概率 | 在问题到达人类前自我纠正 | +| **aiops 实例** | 五层 System Prompt、工具描述、安全规则文本 | 安全 Check 拦截、循环检测、连续失败追踪、自适应检查点 | + +> 仅有前馈,规则永远无法验证是否奏效;仅有反馈,智能体会重复同样的错误。两者缺一不可。 + +### 2.2 计算型 vs 推理型 + +| 类型 | 特点 | 例子 | +|------|------|------| +| **计算型(Computational)** | 确定性、快速、CPU 执行 | 黑名单匹配、命令风险分级、循环检测、token 估算 | +| **推理型(Inferential)** | 语义分析、AI 审查、LLM 调用 | Evolver 用 LLM 提炼经验、LLM 决定下一步工具 | + +计算型传感器成本低、速度快,适合每次工具调用都运行(如 `safety.Controller.Check`、`AssessBashRisk`)。推理型传感器更强大但更贵、不确定,适合关键节点(如任务完成后的经验提炼)。 + +推理型传感器的特别威力:当反馈信号被优化为 LLM 可消费的格式时(如把「连续失败」翻译为「请换一种思路」的中文提示注入对话),形成一种**积极的 prompt injection** —— 这正是 aiops 自适应检查点的设计依据。 + +### 2.3 Steering Loop 掌舵循环 + +Steering Loop 是 Harness Engineering 的元过程 —— 通过迭代改进 Harness 来引导智能体。 + +``` +┌──────────┐ Agent 工作 ┌──────────┐ 系统改进 ┌──────────┐ +│ Harness │ ──────────► │ 观察问题 │ ──────────► │ 更新Harness│ +│ (当前版本) │ │(多次发生) │ │ (新版本) │ +└──────────┘ └──────────┘ └──────────┘ + ▲ │ + └────────────────────────────────────────────────────┘ + 循环往复 +``` + +在 aiops Agent CLI 中,Steering Loop 有两种实现: + +1. **人工 Steering**:管理员在「大模型设置」调整 SystemPrompt、温度等 → 影响 Agent 下次行为 +2. **自动 Steering**:Evolver 引擎观察任务执行 → 自动学习经验 → 注入下次 Prompt(详见第 08 篇) + +后者是 opsxcli 设计思想的核心延伸 —— 用代码自动化 Steering Loop。 + +### 2.4 Keep Quality Left — 把质量左移 + +在 aiops Agent CLI 的执行链路中,反馈传感器按成本分布: + +| 阶段 | 应运行的传感器 | 特点 | +|------|---------------|------| +| **工具执行前** | 黑名单硬拒、`AssessBashRisk` 分级、安全模式阈值 | 毫秒级,每次必跑 | +| **工具执行中** | context 超时(`ToolTimeout` 120s 兜底) | 防卡死 | +| **轮次间** | 自适应检查点(Round 4/8/12)、循环检测(>3 次拒绝)、连续失败追踪 | 防 LLM 跑偏 | +| **任务完成后** | Evolver 异步学习(30s 超时) | 较贵,异步 | + +--- + +## 3. 三类调节 Harness + +> Agent Harness 如同一个控制论调节器(governor),通过前馈和反馈将系统调节到期望状态。 + +### 3.1 可维护性 Harness(Maintainability) + +- 计算型:文件大小红线(Go handler ≤400 行、组件 ≤700 行)、`go vet`、`tsc --noEmit` +- 推理型:代码审查(review) +- 在 Agent CLI 代码中体现为「引擎库零业务依赖」「单文件 ≤300 行」等约束 + +### 3.2 架构适应度 Harness(Architecture Fitness) + +- 前馈:分层依赖约定(controller → model + pkg/agentcli,引擎库不反向 import) +- 反馈:编译失败即拦截(Go 强类型天然传感器) + +### 3.3 行为 Harness(Behaviour)— 本项目的重点 + +如何引导和感知 Agent 是否按预期功能运行: + +- 前馈:五层 System Prompt(角色 + 安全规则 + 工作方式 ReAct 范式) +- 反馈: + - 安全层硬拦截 + 高危确认(防破坏性操作) + - ReAct 循环中的自适应检查点(防 LLM 死循环或编造输出) + - WebSocket 事件流的可观测性(人类可随时观察思考与命令) + - Evolver 经验学习(成功的工具序列被强化,失败的被记录为「换法」提示) + +--- + +## 4. Harnessability 与设计启示 + +### 4.1 可 Harness 性 + +aiops 后端是 Go 强类型 + 清晰的包边界,天然适合被 Harness: + +- 强类型使 LLM 工具的 JSON Schema 可以严格约束参数 +- 清晰的 controller / model / pkg 分层使「引擎库零业务依赖」可行 +- 复用 aiops 既有的凭证系统、SSH 包、DB,大幅缩减 Harness 代码量 + +### 4.2 Ashby 必要多样性定律 + +> 调节器必须拥有至少与被调节系统相同的多样性。 + +aiops Agent CLI 通过两层多样性缩减: + +1. **工具集精简**(6 → 2):只保留 `ssh_execute` + `analyze_output`,把「在远程主机做任何事」收敛为「执行 shell + 分析输出」,让 LLM 的决策空间可控 +2. **Prompt 工作方式约束**:明确告诉 LLM「一次只做关键的、可验证的一步」「优先只读命令」「失败要换法重试」 + +这是把潜在的「无限可能的 Agent」收敛为「可控的运维 Agent」的关键策略。 + +### 4.3 Harness 模板 + +aiops Agent CLI 本身就是一个「Web 版运维 Agent Harness 模板」—— 它的前馈(五层 Prompt)、反馈(安全 + 检查点 + Evolver)、工具协议(Tool 接口)可以被视为同类产品的参考实现。 + +--- + +## 5. 反馈回路设计 + +### 5.1 多轮自我评审循环 + +aiops ReAct 循环本质就是一种多轮自我评审: + +``` +Round 1:LLM 思考 + 决定工具 + │ +Round 2:执行工具 → 观察输出(exit_code 是关键信号) + │ +Round 3:根据输出判断 → 继续排障或修复 + │ +═══ 检查点 Round 4 ═══ + ├─ 若重复调用率 >50% 或连续失败 ≥3 → 注入「换思路」提示 + │ +Round N:… 直到 LLM 不再返回 tool_calls + │ +═══ 检查点 Round 8 ═══ 强制中间总结 +═══ 检查点 Round 12 ═══ 敦促收尾 + │ +最终答案 + Evolver 学习(后台) +``` + +### 5.2 黄金原则 + +从 OpenAI Codex 实践提炼的四条原则在 aiops Agent CLI 中的体现: + +1. **优先使用共享抽象**:`pkg/remoteexec`(SSH)、`pkg/agentcli/tools`(Tool 接口)、`pkg/agentcli/llm`(Client 接口),将不变式集中管理 +2. **不使用「YOLO 式」探测**:所有命令先经 `AssessBashRisk` 评估,黑名单硬拒、高危确认,绝不盲目执行 +3. **统一错误处理**:工具返回 `Result{Success, Output, Error}`,错误信息以中文回到对话供 LLM 理解;Evolver 失败优雅降级不阻断主循环 +4. **避免全局状态**:`Engine` 每次任务一个实例,`Registry` 通过 `sync.RWMutex` 保护;会话状态走 DB 不走全局变量 + +--- + +## 6. aiops Agent CLI 代码对照 + +### 6.1 前馈指导实例 + +| 理论概念 | aiops 实现 | 代码位置 | +|----------|-----------|----------| +| 角色与能力引导 | 五层 System Prompt | `pkg/agentcli/prompt/prompt.go` | +| 工具能力披露 | Registry 动态生成工具描述 | `core/agent.go: buildToolDescriptions` | +| 工作范式引导 | ReAct 范式文本(思考-行动-观察-循环-收尾) | `prompt.go` Layer 4 | +| 安全规则文本 | 黑名单 + 优先只读 + 高危确认 | `prompt.go` Layer 2 | + +### 6.2 反馈传感器实例 + +| 理论概念 | aiops 实现 | 代码位置 | +|----------|-----------|----------| +| 计算型反馈(执行前) | 黑名单硬拒 + 风险分级 | `tools/risk.go` + `safety/safety.go: Check` | +| 计算型反馈(轮次间) | 循环检测(同参 >3 拒绝) | `core/agent.go: processToolCalls` | +| 计算型反馈(轮次间) | 连续失败追踪 | `core/agent.go: updateConsecutiveFailures` | +| 结构化反馈 | 自适应检查点(Round 4/8/12 注入提示) | `core/agent.go: checkAdaptiveCheckpoint` | +| 上下文保护 | 消息裁剪 + tool 消息完整性修复 | `core/agent.go: trimMessages` + `ensureToolMessageIntegrity` | +| 安全反馈 | 三级模式阈值 + 高危确认 | `safety/safety.go` | + +### 6.3 Steering Loop 实例 + +aiops 的 **Evolver 自进化引擎**是自动 Steering Loop 的代码级实现: + +``` +EvolverEngine (pkg/agentcli/evolver/) + │ + ├── engine.go 精简进化循环(OBSERVE→CLASSIFY→LEARN→STORE→MEMORIZE→PREDICT) + └── memory.go 四层记忆(experience/factual/procedural/environment) +``` + +每次任务执行后,Evolver 异步学习并存储经验;下次任务开始时,`GetContextForPrompt()` 把相关历史经验注入 System Prompt 的 Layer 5 —— 这就是「观察问题 → 改进 Harness」的自动化实现。 + +### 6.4 品味不变式实例 + +| 类别 | 不变式 | 理由 | +|------|--------|------| +| 语言 | 注释和错误信息使用中文,署名 `// Logan.Li` | 项目规范 | +| 命名 | 导出函数 PascalCase,包名全小写 | Go 标准 | +| 错误 | `Result.Error` 携带中文错误,Evolver 失败降级不 panic | 保留错误链,不阻断主流程 | +| 依赖 | controller → model + pkg/agentcli(单向) | 引擎库零业务依赖 | +| 日志 | 引擎 emit Event,由 controller 决定落库还是推 WS | 引擎不直接依赖 DB/WS | +| 安全 | 黑名单硬拒不可被确认放行 | 安全红线 | + +--- + +## 7. Web 可观测性 + +> opsxcli 是 CLI 工具,可观测性 = slog + 审计日志 + `--debug`。 +> aiops Agent CLI 是 Web 服务,可观测性 = **WebSocket 事件流 + DB 持久化 + 会话历史回放**。 + +### 7.1 事件流(实时) + +引擎通过 `EventCallback` 推送结构化事件,controller 序列化为 `wsEvent` 后推 WebSocket: + +| Event.Type | 含义 | 前端渲染 | +|------------|------|----------| +| `progress` | 轮次 + token | 顶部进度条 | +| `thought` | AI 思考文本 | 思考气泡 | +| `tool_call` | 即将执行的工具 + 风险 | 工具调用卡片(含风险标签) | +| `tool_result` | 工具输出 + 成败 + 耗时 | 工具结果卡片 | +| `confirm` | 高危确认请求 | useConfirm 弹框 | +| `done` | 最终答案 | 完成气泡 | +| `error` | 错误(含达到最大轮次) | 错误气泡 | + +### 7.2 持久化(事后审计) + +每条事件(除 progress)落库 `agent_cli_messages`,前端「会话历史」页面可按 session_id 回放完整 ReAct 推理过程: + +```go +// controller/agent_cli.go: persistMsg +// 每条消息记录:round / type / role / content / tool_name / tool_args / +// tool_output / risk / success / duration / tokens +``` + +> 与 opsxcli 的差异:opsxcli 把审计日志写到 `~/.opsxcli/audit/audit.log`(文件),aiops 直接走 DB(多用户、可查询、可关联用户与主机)。 + +--- + +## 8. 参考资源 + +| 资源 | 说明 | +|------|------| +| [Martin Fowler — Harness Engineering](https://martinfowler.com/articles/harness-engineering.html) | 理论框架,控制论视角 | +| [OpenAI — Harness Engineering](https://openai.com/zh-Hans-CN/index/harness-engineering) | 实践报告,Codex 实战经验 | +| [LangChain — Agent = Model + Harness](https://blog.langchain.com/the-anatomy-of-an-agent-harness/) | 术语定义 | +| [Anthropic — Effective Harnesses for Long-Running Agents](https://www.anthropic.com/engineering/effective-harnesses-for-long-running-agents) | 长时间运行 Agent 的 Harness 设计 | + +> 作者:Logan.Li diff --git a/docs/agent/04-插件架构设计.md b/docs/agent/04-插件架构设计.md new file mode 100644 index 0000000..8eb0988 --- /dev/null +++ b/docs/agent/04-插件架构设计.md @@ -0,0 +1,177 @@ +# 04 - 插件架构设计 + +> 本文由 opsxcli 同名设计文档适配改写,结合 aiops Agent CLI 的 Web 实现(`backend/pkg/agentcli/`)。 +> opsxcli 的「plugins/」CLI 插件层(mysql/ssh/ping 等 46+ 命令)在 aiops 中不适用 —— Web 版聚焦远程运维,工具精简为 2 个并改由 `tools.Tool` 接口承载。 + +## 工具(Tool)= aiops 版的「插件」 + +opsxcli 有完整的 CLI 插件生态(46+ 命令插件);aiops Agent CLI 把「插件」概念收敛为更纯粹的 **Tool 接口**: + +- 不再有 CLI 命令、参数解析、帮助文本 +- 工具的唯一职责是:被 LLM 调用 → 在目标主机做事 → 返回结构化结果 +- LLM 通过 function calling 协议感知工具的存在与用法 + +> 这是合理的精简:Web Agent 的能力边界由「Tool 接口 + LLM 推理」共同决定,不再需要 CLI 命令那一层。 + +## Tool 接口(5 方法) + +```go +// backend/pkg/agentcli/tools/registry.go + +type Tool interface { + Name() string // 工具名(LLM 调用时用) + Description() string // 给 LLM 看的描述 + Parameters() map[string]interface{} // JSON Schema 参数定义 + RiskLevel() RiskLevel // 静态风险等级 + Execute(ctx context.Context, args map[string]interface{}) (*Result, error) +} +``` + +设计哲学:**接口最小化** —— 5 个方法覆盖工具的完整生命周期。 + +### DynamicRiskTool 扩展接口 + +```go +type DynamicRiskTool interface { + Tool + RiskLevelForArgs(args map[string]interface{}) RiskLevel +} +``` + +部分工具的「静态风险」无法反映真实风险(如 `ssh_execute` 静态是 High,但 `df -h` 是 Safe、`rm -rf /` 是 Critical)。这些工具额外实现 `DynamicRiskTool`,安全控制器 `Check` 时优先调用动态评估。 + +### Result 结构 + +```go +type Result struct { + Success bool `json:"success"` + Output string `json:"output"` + Error string `json:"error,omitempty"` +} +``` + +## Registry 注册中心 + +```go +type Registry struct { + tools map[string]Tool + mu sync.RWMutex // 读写锁,支持并发注册和查询 +} +``` + +核心方法: + +| 方法 | 说明 | +|------|------| +| `NewRegistry()` | 创建空注册中心 | +| `Register(tool)` | 注册工具(线程安全) | +| `Get(name)` | 按名获取工具(LLM 调用时按名查找) | +| `List()` | 列出所有工具(构建 Prompt Layer 3 工具描述) | +| `ToLLMTools()` | 转换为 OpenAI function calling 格式(自动注入到 LLM `tools` 参数) | + +`ToLLMTools()` 是 Registry 与 LLM 协议的桥梁:注册的工具自动出现在 LLM 的 function 列表中,无需手动维护。 + +## 已注册工具一览 + +> 与 opsxcli 的关键差异:opsxcli 有 6 个工具(local_bash / ssh_execute / scp_transfer / file_read / file_search / analyze_output),aiops 精简为 2 个。 + +| 工具 | 风险等级 | 功能 | 来源 | +|------|----------|------|------| +| `ssh_execute` | High(静态)/ Safe~Critical(动态) | 在目标主机远程执行 shell 命令 | aiops 既有 `pkg/remoteexec` | +| `analyze_output` | Safe | 对冗长输出做摘要/错误提取 | 内置 Go 实现 | + +### 为什么精简? + +| opsxcli 工具 | aiops 处理 | 原因 | +|--------------|-----------|------| +| `local_bash` | **剥离** | Web 后端不应在服务器本地执行命令(安全风险 + 多用户隔离) | +| `scp_transfer` | **剥离** | 文件传输走 aiops 既有的「文件管理」模块,不混入 Agent | +| `file_read` | **剥离** | 远程读文件用 `ssh_execute cat`,无需独立工具 | +| `file_search` | **剥离** | 远程搜文件用 `ssh_execute grep/find` | +| `ssh_execute` | **保留** | 核心远程运维能力 | +| `analyze_output` | **保留** | 防止长输出 token 爆炸,辅助 LLM 聚焦关键信息 | + +工具越少,LLM 的决策空间越收敛(呼应 Ashby 定律),首次做对的概率越高。 + +## 工具分类标准(aiops 版) + +### 放入内置工具集的条件(需同时满足) + +1. 属于运维 Agent 高频需要的能力 +2. 无法用现有工具简单组合完成(如 `analyze_output` 不能用 `ssh_execute` 替代 —— 它操作的是已有文本而非远程命令) +3. 实现简洁(单文件 < 300 行) + +### 通过 `ssh_execute` 间接覆盖的能力 + +opsxcli 用独立插件实现的能力,在 aiops 中全部由 `ssh_execute` 执行远程命令覆盖: + +| opsxcli 插件能力 | aiops 等效 | +|------------------|-----------| +| `mysql -e "..."` | `ssh_execute "mysql -e \"...\""` | +| `redis-cli ...` | `ssh_execute "redis-cli ..."` | +| `ping / traceroute` | `ssh_execute "ping ..."` | +| `cat / grep / find` | `ssh_execute "cat ..."` | +| `systemctl status nginx` | `ssh_execute "systemctl status nginx"` | + +LLM 会根据任务自主决定执行什么命令,无需为每类命令提供独立工具。 + +## 混合架构的好处 + +1. **职责单一**:每个工具只做一件事,`ssh_execute` 执行、`analyze_output` 分析 +2. **协议简单**:LLM 只需理解 2 个 function,决策快、错误少 +3. **风险可控**:所有远程操作统一走 `ssh_execute` 的风险评估(`AssessBashRisk`),不会因为工具增多而出现安全盲区 +4. **扩展灵活**:未来若需要新工具(如 `kubectl_apply`),实现 Tool 接口注册即可 + +## 扩展指南 + +### 添加新工具 + +1. 实现 `Tool` 接口(必要时实现 `DynamicRiskTool`): + +```go +// pkg/agentcli/tools/kubectl_get.go +type KubectlGetTool struct{ cfg remoteexec.SSHConfig } + +func (t *KubectlGetTool) Name() string { return "kubectl_get" } +func (t *KubectlGetTool) Description() string { + return "在目标主机执行 kubectl get 并返回结果" +} +func (t *KubectlGetTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "resource": map[string]interface{}{"type": "string", "description": "如 pods/deployments"}, + }, + "required": []string{"resource"}, + } +} +func (t *KubectlGetTool) RiskLevel() tools.RiskLevel { return tools.RiskSafe } +func (t *KubectlGetTool) Execute(ctx context.Context, args map[string]interface{}) (*tools.Result, error) { + // 复用 remoteexec 执行 kubectl get +} +``` + +2. 在 `agentcli.NewEngine` 中注册: + +```go +registry.Register(tools.NewSSHExecuteTool(cfg.SSH, coreCfg.ToolTimeout)) +registry.Register(tools.NewAnalyzeOutputTool()) +registry.Register(tools.NewKubectlGetTool(cfg.SSH)) // 新增 +``` + +3. 工具自动出现在 LLM 的 function 列表与 Prompt Layer 3 工具描述中。无需改 Prompt、controller、前端。 + +## 常见陷阱 + +| 陷阱 | 解决方案 | +|------|----------| +| 忘记设置 `RiskLevel` 导致误判 | 默认按接口返回值,高危工具必须显式声明 | +| 高危工具未实现 `DynamicRiskTool` | 静态等级无法反映参数差异(如 `ssh_execute` 看 `df` 还是 `rm -rf`),必须实现动态评估 | +| 工具 Execute 无超时 | `Config.ToolTimeout=120s` 兜底(`context.WithTimeout`) | +| 工具输出过长导致 token 爆炸 | `OutputMaxLength=10000` 截断 + 引导 LLM 调用 `analyze_output` | + +## 总结 + +aiops Agent CLI 的「插件架构」是 opsxcli 的精简版:从「46+ CLI 插件」收敛为「2 个 Tool 接口实现」。这不是能力的退化,而是**收敛决策空间、聚焦远程运维、降低 LLM 误判概率**的有意设计。详细工具实现见 [05 - Builtin 内置工具](05-Builtin内置命令.md)。 + +> 作者:Logan.Li diff --git a/docs/agent/05-Builtin内置命令.md b/docs/agent/05-Builtin内置命令.md new file mode 100644 index 0000000..e611296 --- /dev/null +++ b/docs/agent/05-Builtin内置命令.md @@ -0,0 +1,248 @@ +# 05 - Builtin 内置工具 + +> 本文由 opsxcli 同名设计文档适配改写,结合 aiops Agent CLI 的 Web 实现(`backend/pkg/agentcli/`)。 +> opsxcli 原文讨论「builtin 基础命令(ls/cat/grep 等)的 Go 原生实现 vs 命令转发」抉择;aiops 版剥离了所有本地命令,仅保留 2 个工具:`ssh_execute`(远程执行)+ `analyze_output`(输出分析)。本文聚焦这 2 个工具的设计。 + +## 设计目标 + +为 aiops Agent CLI 提供**最小而充分**的工具集: + +- `ssh_execute` 覆盖所有「在远程主机做事」的需求(排障、安装、配置、重启) +- `analyze_output` 解决「命令输出过长导致 LLM token 爆炸」的问题 + +工具数量从 opsxcli 的 6 个精简为 2 个,但能力边界通过 LLM 自主编排命令来扩展,而非通过堆工具。 + +## 工具 1:ssh_execute(远程命令执行) + +### 定位 + +在目标主机上执行任意 shell 命令,返回合并的 stdout + stderr + exit_code。这是 Agent 真正「动手」的唯一通道。 + +### 实现 + +```go +// backend/pkg/agentcli/tools/ssh_execute.go + +type SSHExecuteTool struct { + cfg remoteexec.SSHConfig // 目标主机连接配置(含凭据,由 controller 注入) + timeout time.Duration // 默认命令超时(60s) +} + +func (t *SSHExecuteTool) Name() string { return "ssh_execute" } + +func (t *SSHExecuteTool) Description() string { + return fmt.Sprintf( + "在远程服务器 %s(用户 %s)上执行 shell 命令并返回合并的 stdout+stderr 输出与退出码。"+ + "用于排障(查看日志/进程/磁盘/网络)、安装与配置软件、重启服务等。"+ + "高危命令(rm -rf /、mkfs、reboot 等)需用户确认。", host, user) +} + +func (t *SSHExecuteTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "command": map[string]interface{}{ + "type": "string", + "description": "要执行的 shell 命令,例如 df -h、systemctl status nginx、tail -n 100 /var/log/messages", + }, + "timeout": map[string]interface{}{ + "type": "integer", + "description": "命令超时秒数(默认 60)", + }, + }, + "required": []string{"command"}, + } +} +``` + +### 风险评估:DynamicRiskTool + +`ssh_execute` 是典型「静态等级无法反映真实风险」的工具,因此实现 `DynamicRiskTool`: + +```go +// 静态等级:High(保守) +func (t *SSHExecuteTool) RiskLevel() RiskLevel { return RiskHigh } + +// 动态等级:看具体命令 +func (t *SSHExecuteTool) RiskLevelForArgs(args map[string]interface{}) RiskLevel { + return AssessBashRisk(StringParam(args, "command")) +} +``` + +| 命令示例 | 动态风险 | 安全模式 balanced 下的行为 | +|----------|----------|--------------------------| +| `df -h` / `ps aux` / `cat /etc/hosts` | Safe | 直接执行 | +| `mkdir /tmp/foo` / `touch x` | Low | 直接执行 | +| `chmod 644 /etc/cfg` / `cp a b` | Medium | strict 需确认,balanced 放行 | +| `systemctl restart nginx` / `kill 1234` | High | 弹框确认 | +| `rm -rf /var/log/*` | Critical | **硬拒**(不可确认放行) | + +### Execute 流程 + +```go +func (t *SSHExecuteTool) Execute(ctx context.Context, args map[string]interface{}) (*Result, error) { + command := strings.TrimSpace(StringParam(args, "command")) + if command == "" { + return &Result{Success: false, Error: "参数 command 不能为空"}, nil + } + // ... timeout 解析 + res, err := remoteexec.Exec(t.cfg, command, timeout) + if err != nil { + return &Result{Success: false, Error: "SSH 连接失败: " + err.Error()}, nil + } + // 关键:附带 exit_code,让 LLM 判断命令是否成功(排障关键信号) + output := res.Output + fmt.Sprintf("[exit_code=%d]", res.ExitCode) + return &Result{Success: res.Success, Output: output, Error: res.Error}, nil +} +``` + +**设计要点**: + +- **复用 aiops 既有 `pkg/remoteexec`**:Go-native SSH,无外部依赖,凭据由 controller 从 `model.Server` 关联凭证系统构建后注入 +- **`exit_code` 是关键信号**:LLM 通过 `exit_code` 判断命令是否真正成功(避免 LLM 误以为「输出有内容」就是「执行成功」) +- **连接级失败与命令级失败分离**:SSH 连不上返回 `Error`,命令执行返回 `exit_code != 0`,让 LLM 区分排查方向 +- **凭据不暴露给 LLM**:`SSHConfig` 是工具私有的,LLM 只看到 `command` 参数 + +## 工具 2:analyze_output(输出分析) + +### 定位 + +对一段(可能很长的)命令输出做摘要:统计行数、提取疑似错误/告警行、必要时截断保留首尾。**纯文本处理,不执行任何命令**,因此 `RiskSafe`。 + +### 为什么需要它? + +LLM 上下文窗口有限(即使 128k 模型,超长输出也会显著增加成本和延迟)。`ssh_execute` 单次输出截断在 10000 字符,但即便如此,日志类输出(如 `journalctl -n 1000`)仍可能让 LLM 难以聚焦。 + +`analyze_output` 让 LLM 自主决定「输出太长时先分析再推理」: + +``` +Round 3: ssh_execute "journalctl -u nginx -n 500" + → 输出 500 行 +Round 4: analyze_output(output, focus="error") + → 提取 8 行疑似错误 + 截断保留首尾 +Round 5: LLM 基于精炼后的信息推理 +``` + +### 实现 + +```go +// backend/pkg/agentcli/tools/builtin.go + +func (t *AnalyzeOutputTool) Execute(ctx context.Context, args map[string]interface{}) (*Result, error) { + output := StringParam(args, "output") + focus := strings.ToLower(StringParam(args, "focus")) + // 1. 超长截断:保留前 40 + 后 40 行,中间用省略标注 + // 2. 提取疑似错误/告警行(errorKeywords,含中英文) + // - 英文:error / fail / exception / traceback / denied / timeout / refused / critical / fatal + // - 中文:错误 / 失败 / 拒绝 / 超时 / 无法 / 异常 + // - 若传 focus,把 focus 作为额外关键词优先匹配 + // 3. 最多 20 条疑似错误行 + // 4. 组装摘要:总行数 + 截断标注 + 错误行 + 输出片段 +} +``` + +**错误关键词列表**: + +```go +errorKeywords := []string{ + "error", "fail", "exception", "traceback", "denied", + "permission", "timeout", "refused", "unavailable", "critical", "fatal", + "错误", "失败", "拒绝", "超时", "无法", "异常", +} +``` + +**输出格式示例**: + +``` +总行数: 500(已截断保留首尾) +疑似错误/告警行 (8): + nginx: [emerg] bind() to 0.0.0.0:80 failed (98: Address already in use) + nginx: [emerg] bind() to [::]:80 failed (98: Address already in use) + ... + +输出片段: +... (前 40 行) +... [已省略 420 行] ... +... (后 40 行) +``` + +## 工具组合:典型 ReAct 序列 + +`ssh_execute` 与 `analyze_output` 的组合覆盖绝大多数运维场景: + +### 场景 1:磁盘满排障 + +``` +Round 1: ssh_execute "df -h" → 发现 /var 满 +Round 2: ssh_execute "du -sh /var/*" → 定位 /var/log 最大 +Round 3: ssh_execute "ls -lah /var/log | head -20" → 找到 access.log 巨大 +Round 4: 给出清理建议(不直接 rm,让用户确认) +``` + +### 场景 2:服务启动失败 + +``` +Round 1: ssh_execute "systemctl status nginx" → failed +Round 2: ssh_execute "journalctl -u nginx -n 500" → 500 行日志 +Round 3: analyze_output(output, focus="error") → 提取 8 行错误,端口被占 +Round 4: ssh_execute "ss -tlnp | grep :80" → 确认 80 端口被占用 +Round 5: 给出根因 + 解决建议 +``` + +### 场景 3:性能排查 + +``` +Round 1: ssh_execute "top -bn1 | head -20" → CPU 100% 进程 +Round 2: ssh_execute "ps aux --sort=-%cpu | head -10" → 定位 java 进程 +Round 3: ssh_execute "jstack " → 线程栈 +Round 4: analyze_output(jstack_output, focus="BLOCKED") → 死锁线索 +Round 5: 给出根因(线程死锁) +``` + +## 工具与 Prompt 的协作 + +Prompt Layer 2 明确告诉 LLM: + +> 排障时优先使用只读命令(df/ps/cat/grep/tail/systemctl status/journalctl)收集信息,确认根因后再动手修复。 + +Prompt Layer 3 自动列出工具描述(来自 `buildToolDescriptions()`): + +``` +【可用工具】 +- ssh_execute: 在远程服务器 ... 上执行 shell 命令 ... +- analyze_output: 分析一段(可能很长的)命令输出文本 ... +``` + +工具的命名和描述本身就是给 LLM 的「使用说明书」,描述写得好,LLM 就能正确选用。 + +## 扩展指南 + +### 何时应该新增工具(而非用 ssh_execute)? + +满足以下任一条件: + +1. **命令协议复杂、LLM 容易写错**(如 `kubectl apply -f` 需要 YAML 参数,用 `ssh_execute` 难以表达) +2. **需要结构化输入/输出**(如数据库查询返回表结构) +3. **需要专门的风险评估逻辑**(如 `kubectl delete` 需要按资源类型分级) + +如果只是「执行某条命令」,用 `ssh_execute` 即可,不需要新工具。 + +### 实现步骤 + +见 [04 - 插件架构设计](04-插件架构设计.md) 的「添加新工具」章节。 + +## 常见陷阱 + +| 陷阱 | 解决方案 | +|------|----------| +| `ssh_execute` 输出过长撑爆上下文 | `OutputMaxLength=10000` 截断 + 引导调用 `analyze_output` | +| LLM 把「输出有内容」误判为「命令成功」 | 输出末尾附 `[exit_code=N]`,Prompt 显式提醒 | +| `analyze_output` 关键词覆盖不全 | 扩展 `errorKeywords` 列表(含中英文) | +| 命令超时无反馈 | `context.WithTimeout(ctx, ToolTimeout)` + 失败信息回写 | +| 凭据泄漏给 LLM | `SSHConfig` 是工具私有字段,不在 `Parameters` 中暴露 | + +## 总结 + +aiops Agent CLI 的工具集设计遵循「少即是多」:2 个工具 + LLM 自主编排,覆盖绝大多数远程运维场景。`ssh_execute` 是「手」(动手做事),`analyze_output` 是「放大镜」(聚焦关键信息),二者配合让 Agent 既有行动力又有判断力。 + +> 作者:Logan.Li diff --git a/docs/agent/06-Agent核心引擎设计.md b/docs/agent/06-Agent核心引擎设计.md new file mode 100644 index 0000000..f047134 --- /dev/null +++ b/docs/agent/06-Agent核心引擎设计.md @@ -0,0 +1,415 @@ +# 06 - Agent 核心引擎设计 + +> 本文由 opsxcli 同名设计文档适配改写,结合 aiops Agent CLI 的 Web 实现(`backend/pkg/agentcli/core/agent.go`)。 +> 核心算法(16 轮 ReAct 主循环、自适应检查点、循环检测、连续失败追踪、Evolver 集成)全部保留;TUI 回调(Bubble Tea)剥离,改为 `EventCallback` 推 WebSocket 事件流。 + +## 设计理念 + +Agent 核心引擎遵循 **ReAct (Reasoning + Acting)** 范式: + +- **Reasoning**:LLM 推理决定下一步行动(产出 `thought` 文本 + 工具调用计划) +- **Acting**:调用工具执行具体操作(`ssh_execute` / `analyze_output`) +- **Observing**:观察工具结果(`exit_code`、输出、耗时),反馈到下一轮推理 + +通过 16 轮迭代循环,让 Agent 从「盲目执行命令」进化为「有目的地探索和解决问题」。 + +## 核心结构 + +```go +// backend/pkg/agentcli/core/agent.go + +type Agent struct { + llm llm.Client // LLM 客户端(OpenAI 兼容) + registry *tools.Registry // 工具注册中心 + config *Config // 运行配置 + safetyCtl *safety.Controller // 安全控制器 + evolver *evolver.EvolverEngine // 自进化引擎(可为 nil,降级运行) + callback EventCallback // 事件回调(controller 注入,推 WS + 落库) + totalTokens int // token 消耗统计 + mu sync.Mutex +} +``` + +> 与 opsxcli 的差异: +> - 剥离 `tokenizer *TokenEstimator` 独立结构,改为内联的 `estimateTokens` 函数 +> - 剥离 `lastEvolveResult` + `evolveResultMu`,改为 Evolver 内部维护 +> - 剥离 `evolveWg sync.WaitGroup`,进化 goroutine 自带 `recover()`,不阻塞主循环 + +### 依赖关系 + +``` + ┌─────────┐ + │ Agent │ + └────┬────┘ + ┌───────┬───────┼───────┬──────────┐ + │ │ │ │ │ + ▼ ▼ ▼ ▼ ▼ + ┌────────┐┌──────┐┌──────┐┌──────┐┌────────┐ + │Config ││Safety││Tools ││Evolver││Prompt │ + │配置 ││安全 ││工具 ││进化 ││构建器 │ + └────────┘└──────┘└──────┘└──────┘└────────┘ +``` + +## EventCallback —— 替代 TUI 的事件流 + +opsxcli 通过 `ToolCallback` 驱动 Bubble Tea TUI 实时渲染;aiops 改为 `EventCallback`,由 controller 序列化后推 WebSocket + 落库: + +```go +type EventType string + +const ( + EventThought EventType = "thought" // AI 思考文本 + EventToolCall EventType = "tool_call" // 即将执行的工具(名/参数/风险) + EventToolResult EventType = "tool_result" // 工具执行结果(输出/成败/耗时) + EventProgress EventType = "progress" // 轮次 + token + EventDone EventType = "done" // 任务完成(最终答案) + EventError EventType = "error" // 错误(含达到最大轮次) +) + +type EventCallback func(Event) +``` + +controller 的 `onEvent` 实现同时做两件事: + +```go +func (ac *agentConn) onEvent(e core.Event) { + ac.write(wsEvent{...}) // 1. 推 WebSocket + ac.persistMsg(...) // 2. 落库 agent_cli_messages(progress 除外) +} +``` + +## Config 配置 + +```go +// backend/pkg/agentcli/core/agent.go + +type Config struct { + MaxIterations int // 16 - 最大迭代次数 + Temperature float64 // 0.3 - LLM 温度(偏低 = 更确定) + ToolTimeout time.Duration // 120s - 工具执行超时 + MaxTokens int // 4096 - 单次 LLM 最大输出 token + SafetyMode safety.Mode // balanced + OutputMaxLength int // 10000 - 输出截断长度 + MaxContextTokens int // 8000 - 上下文窗口(裁剪阈值 90%) + MemoryDir string // data/agentcli - Evolver 记忆目录 +} +``` + +默认值通过 `DefaultConfig()` 提供。controller 可通过 `EngineConfig.Core` 覆盖。 + +> 与 opsxcli 的差异:opsxcli 用 `LoadConfig()` 从环境变量加载(`OPSXCLI_AGENT_*`),aiops 改为代码默认值 + `EngineConfig` 显式覆盖(Web 场景下环境变量管理不直观)。 + +## 16 轮 ReAct 主循环 + +### 流程图 + +``` +用户输入 + │ + ▼ +┌─────────────────────────────────────────────┐ +│ ReAct Main Loop │ +│ │ +│ Round 1 ──→ LLM 推理 → 工具调用 → 观察 │ +│ Round 2 ──→ LLM 推理 → 工具调用 → 观察 │ +│ Round 3 ──→ LLM 推理 → 工具调用 → 观察 │ +│ ═══════ CHECKPOINT 1 (Round 4) ═══════ │ +│ Round 4 ──→ 启发式评估 + 纠偏提示 │ +│ ... │ +│ Round 7 ──→ LLM 推理 → 工具调用 → 观察 │ +│ ═══════ CHECKPOINT 2 (Round 8) ═══════ │ +│ Round 8 ──→ 强制中间总结 │ +│ ... │ +│ Round 11 ──→ LLM 推理 → 工具调用 → 观察 │ +│ ═══════ CHECKPOINT 3 (Round 12) ═════ │ +│ Round 12 ──→ 最终提醒,敦促收尾 │ +│ ... │ +│ Round 16 ──→ 最大迭代,强制结束 │ +└──────────────┬──────────────────────────────┘ + │ + ▼ + 最终回答 / 总结 + │ + ▼ (后台异步) + ┌──────────────┐ + │ Evolver 学习 │ + └──────────────┘ +``` + +### 为什么是 16 轮? + +| 迭代次数 | 优点 | 缺点 | +|----------|------|------| +| 8 轮 | 快速响应 | 复杂运维任务不够用 | +| **16 轮** | **覆盖 90% 运维场景** | **偶尔多消耗 token** | +| 32 轮 | 兜底极端场景 | 成本高、用户等待久 | + +16 轮是**能力与效率的平衡点**,配合自适应检查点避免无意义循环。 + +### 主循环核心代码(精简版) + +```go +for iter := 0; iter < a.config.MaxIterations; iter++ { + if err := ctx.Err(); err != nil { return nil, err } // 中断检查 + a.emit(Event{Type: EventProgress, Round: iter + 1}) + + // 自适应检查点 + if hint := a.checkAdaptiveCheckpoint(iter, ...); hint != "" { + messages = append(messages, llm.Message{Role: "user", Content: hint}) + } + + trimmed := a.trimMessages(messages) // 裁剪历史 + resp, err := a.llm.Complete(ctx, &llm.CompletionRequest{ + Messages: trimmed, Tools: llmTools, + Temperature: a.config.Temperature, MaxTokens: a.config.MaxTokens, + }) + // ... + a.emit(Event{Type: EventThought, Round: iter + 1, Thought: resp.Message.Content}) + + if len(resp.Message.ToolCalls) == 0 { + // 最终答案 + a.emit(Event{Type: EventDone, Answer: resp.Message.Content, ...}) + a.triggerEvolve(ctx, query, serverInfo, answer, toolRecords, true, start) + return &tools.Result{Success: true, Output: answer}, nil + } + + // 处理工具调用(含安全检查、循环检测、风险事件) + observations, records, failInc := a.processToolCalls(ctx, resp.Message.ToolCalls, ...) + messages = append(messages, observations...) +} +// 达到最大轮次 +a.triggerEvolve(ctx, query, serverInfo, "", toolRecords, false, start) +return nil, fmt.Errorf("达到最大迭代次数(%d)...", a.config.MaxIterations) +``` + +## 自适应检查点机制 + +### checkAdaptiveCheckpoint() + +```go +func (a *Agent) checkAdaptiveCheckpoint(iter int, history map[string]int, consecFail, totalCalls int) string { + switch iter { + case 3: // 第 4 轮(0-indexed 为 3) + repeat := 0 + for _, c := range history { + if c > 1 { repeat += c - 1 } + } + if totalCalls > 0 && (float64(repeat)/float64(totalCalls) > 0.5 || consecFail >= 3) { + return "【系统提示】检测到重复使用相同工具或连续失败,请尝试换一种思路或不同命令。" + } + case 7: // 第 8 轮 + return "【系统提示】已执行到中段,请总结进展。信息足够就直接给出最终答案,否则说明剩余步骤。" + case 11: // 第 12 轮 + return fmt.Sprintf("【系统提示】仅剩 %d 步,请立即整理已有信息给出最终答案,不要再调用新工具。", + a.config.MaxIterations-iter-1) + } + return "" +} +``` + +| 检查点 | 轮次 | 策略 | 触发条件 | +|--------|------|------|----------| +| **Tier 1** | Round 4 | 启发式纠偏 | 重复工具调用率 >50% **或** 连续失败 ≥3 | +| **Tier 2** | Round 8 | 强制中间总结 | 始终注入 | +| **Tier 3** | Round 12 | 敦促收尾 | 始终注入 | + +### 设计决策:为什么用消息注入而不是额外 LLM 调用? + +| 方案 | 优点 | 缺点 | +|------|------|------| +| ❌ 额外 LLM 调用评估状态 | 精确 | 每次 +1 API 调用,成本翻倍 | +| ✅ **消息注入** | **零额外成本** | 依赖 LLM 自主理解提示 | + +把引导消息以 `user` 角色注入对话,LLM 在下一轮推理时自然调整行为。这是「计算型反馈 + 积极的 prompt injection」的典型应用(见 [03 - Harness 工程化](03-Harness工程化与反馈回路.md))。 + +## 循环检测 + +防止 LLM 陷入「用相同参数反复调用同一工具」的死循环: + +```go +key := tc.Function.Name + ":" + tc.Function.Arguments +history[key]++ +if history[key] > 3 { + obs = append(obs, llm.Message{ + Role: "tool", ToolCallID: tc.ID, Name: tc.Function.Name, + Content: "错误: 检测到循环调用,相同参数已调用超过3次,请换一种方式", + }) + continue +} +``` + +阈值 >3 次:允许 LLM 重试 2~3 次(合理调试),超过则强制换法。 + +## 连续失败追踪 + +```go +func (a *Agent) updateConsecutiveFailures(obs []llm.Message, current int) int { + errs := 0 + for _, o := range obs { + c := o.Content + if strings.HasPrefix(c, "错误") || strings.HasPrefix(c, "失败") || + strings.HasPrefix(c, "执行错误") || strings.HasPrefix(c, "执行失败") { + errs++ + } + } + if errs == 0 { return 0 } // 有成功就重置 + return current + errs +} +``` + +连续失败 ≥3 次会触发 Tier 1 检查点的纠偏提示。 + +## Token 估算与消息裁剪 + +### estimateTokens(粗略估算) + +```go +func estimateTokens(s string) int { + return len([]rune(s))/3 + 1 +} +``` + +中文约 1 字/token,英文约 1/4,折中 rune/3。够用即可,不需要精确 tokenizer。 + +### trimMessages + +```go +func (a *Agent) trimMessages(msgs []llm.Message) []llm.Message { + maxT := a.config.MaxContextTokens * 9 / 10 // 留 10% 余量 + sysT := estimateTokens(msgs[0].Content) + kept := ... // 从末尾向前累加,超 maxT 停止 + if len(kept) < 4 && len(msgs) > 4 { + kept = msgs[len(msgs)-4:] // 最少保留最近 4 条 + } + result := append([]llm.Message{msgs[0]}, kept...) // system + 最近消息 + return ensureToolMessageIntegrity(result) +} +``` + +### ensureToolMessageIntegrity(关键) + +LLM API 要求 `role: tool` 消息必须紧跟在带 `tool_calls` 的 `assistant` 消息之后,且 ID 匹配。裁剪后可能出现孤立 tool 消息,导致 API 报错: + +``` +Messages with role 'tool' must be a response to a preceding message with 'tool_calls' +``` + +`ensureToolMessageIntegrity` 修复这种孤立: + +```go +// 1. 收集所有 assistant.tool_calls 的 ID +// 2. 遍历 tool 消息: +// - 若 ID 不在 validIDs 中 → 删除 +// - 若前一条不是 assistant 或 ID 不匹配 → 删除 +``` + +## 工具调用处理(processToolCalls) + +完整流程: + +``` +对每个 tool_call: + ├─ 循环检测(>3 次拒绝) + ├─ registry.Get(name) 查找工具 + ├─ json.Unmarshal 解析参数 + ├─ risk = toolRisk(tool, args) ← 优先 DynamicRiskTool + ├─ emit EventToolCall(name, args, risk) + ├─ safetyCtl.Check(tool, args) ← 高危触发 ConfirmFn + │ ├─ 黑名单(Critical)→ 拒绝 + │ ├─ 阈值内 / AutoApprove → 放行 + │ └─ 达阈值 → confirmFn → controller 推 WS confirm → 等前端回传 + ├─ tool.Execute(ctx, args) ← context.WithTimeout(ToolTimeout) + ├─ 输出截断(> OutputMaxLength) + ├─ emit EventToolResult(output, success, duration) + └─ 构建 observation 消息追加到 messages +``` + +## Evolver 集成 + +### triggerEvolve(异步进化) + +```go +func (a *Agent) triggerEvolve(ctx context.Context, query, serverHost, answer string, + records []evolver.ToolCallRecord, success bool, start time.Time) { + if a.evolver == nil { return } // 降级 + exec := &evolver.TaskExecution{...} + go func() { + defer func() { _ = recover() }() // panic 恢复 + eCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _ = a.evolver.Evolve(eCtx, exec) // 错误忽略 + }() +} +``` + +**三层保护**: + +1. **后台 goroutine**:不阻塞主循环,用户立即拿到答案 +2. **30 秒超时**:防止进化过程无限运行 +3. **panic 恢复**:捕获异常,确保 goroutine 崩溃不影响主流程 + +### GetContextForPrompt(经验注入) + +任务开始时(`Run` 入口): + +```go +evolveCtx := "" +if a.evolver != nil { + evolveCtx = a.evolver.GetContextForPrompt(query) // 按 task_type 取历史经验 +} +systemPrompt := prompt.BuildSystemPrompt(serverInfo, toolDescs, evolveCtx) +``` + +注入的内容会被放入 Prompt Layer 5(详见 [07 - Prompt 构建](07-Prompt构建与记忆注入.md) 与 [08 - Evolver](08-Evolver自进化引擎.md))。 + +## Agent 初始化流程 + +``` +NewAgent(client, registry, cfg, sc, ev) + │ + ├─ cfg == nil → DefaultConfig() + ├─ sc == nil → safety.NewController(balanced) + ├─ MaxContextTokens <= 0 → 强制设为 8000(防御性校验) + └─ return Agent +``` + +**优雅降级设计**:Evolver 初始化失败时,`agentcli.NewEngine` 把 `ev` 设为 `nil`,Agent 继续正常工作,只是没有自学习能力。 + +## 扩展指南 + +### 增加新的迭代策略 + +1. 在 `Config` 中增加策略配置项(如新的检查点轮次) +2. 在 `checkAdaptiveCheckpoint()` 中添加新 case +3. 编写对应的中文提示文本 + +### 自定义事件类型 + +1. 在 `EventType` 常量中新增(如 `EventWarning`) +2. 在 `Event` 结构中增加字段 +3. 在 controller 的 `onEvent` 中增加 case 处理(推 WS + 落库) + +## 常见陷阱 + +| 陷阱 | 解决方案 | +|------|----------| +| MaxIterations 设太大导致 token 失控 | 默认 16 + 自适应检查点 | +| trimMessages 后 tool 消息孤立 | `ensureToolMessageIntegrity()` 修复 | +| Evolver 失败导致 Agent 不可用 | 优雅降级,`evolver=nil` 时跳过进化 | +| MaxContextTokens=0 导致上下文异常 | 防御性校验,<=0 时强制 8000 | +| 高并发下 messages 切片竞争 | Agent 不是并发安全的,**每个任务用独立实例**(controller 已保证:每次 startTask 新建 Engine) | +| LLM 不返回 tool_calls 也不返回 content | 视为完成,emit done(空答案) | + +## 与 opsxcli 的差异总结 + +| 维度 | opsxcli | aiops | +|------|---------|-------| +| 回调机制 | `ToolCallback` 驱动 Bubble Tea TUI | `EventCallback` 推 WebSocket + 落库 | +| Token 估算 | 独立 `TokenEstimator` 结构 | 内联 `estimateTokens` 函数 | +| 配置加载 | 环境变量 `OPSXCLI_AGENT_*` | `DefaultConfig()` + `EngineConfig` 覆盖 | +| 进化 goroutine 同步 | `evolveWg.Wait()` | 自带 recover + 30s 超时,不等 | +| MaxContextTokens 默认 | 6000(为 8k 模型留余量) | 8000(适配现代 128k 模型) | + +核心算法(16 轮循环 / 检查点 / 循环检测 / 连续失败追踪 / Evolver 集成)完全保留,差异仅在工程外壳。 + +> 作者:Logan.Li diff --git a/docs/agent/07-Prompt构建与记忆注入.md b/docs/agent/07-Prompt构建与记忆注入.md new file mode 100644 index 0000000..538758f --- /dev/null +++ b/docs/agent/07-Prompt构建与记忆注入.md @@ -0,0 +1,301 @@ +# 07 - Prompt 构建与记忆注入 + +> 本文由 opsxcli 同名设计文档适配改写,结合 aiops Agent CLI 的 Web 实现(`backend/pkg/agentcli/prompt/prompt.go`)。 +> 五层 System Prompt 架构保留;opsxcli 的「personaLayer / capabilityLayer / constraintLayer / fewShotLayer / memoryLayer」五常量在 aiops 中合并为单个 `BuildSystemPrompt()` 函数;角色从「opsxcli 智能运维超级助手」改写为「aiops Agent CLI 智能运维 Agent(Web 版,类似 Claude Code)」。 + +## 设计理念 + +Prompt 是 LLM Agent 的灵魂。aiops Agent CLI 沿用 opsxcli 的 **五层 System Prompt 架构**,将静态知识与动态记忆分层组装: + +- **Layer 1-4 静态层**:编译期确定,不随运行时变化(角色、安全、工具、工作方式) +- **Layer 5 动态层**:运行时由 Evolver 注入历史经验,越用越聪明 + +## 五层 System Prompt 架构 + +``` +┌──────────────────────────────────────────┐ +│ Layer 1: 角色定位 │ ← 我是谁 +│ 「aiops Agent CLI 智能运维 Agent」 │ +├──────────────────────────────────────────┤ +│ Layer 2: 安全规则 │ ← 我必须遵守什么 +│ 黑名单 / 高危确认 / 优先只读 │ +├──────────────────────────────────────────┤ +│ Layer 3: 工具说明 │ ← 我能用什么 +│ 由 Registry 动态生成(ssh_execute 等) │ +├──────────────────────────────────────────┤ +│ Layer 4: 工作方式(ReAct 范式) │ ← 我怎么工作 +│ 思考 → 行动 → 观察 → 循环 → 收尾 │ +├──────────────────────────────────────────┤ +│ Layer 5: 记忆注入 │ ← 我学到了什么(动态) +│ Evolver 提供的历史经验 │ +└──────────────────────────────────────────┘ +``` + +## BuildSystemPrompt 函数 + +```go +// backend/pkg/agentcli/prompt/prompt.go + +func BuildSystemPrompt(serverInfo, toolDescs, evolveContext string) string { + var sb strings.Builder + + // Layer 1: 角色 + sb.WriteString("你是 aiops 平台的 Agent CLI —— 一个企业级智能运维 Agent," + + "类似 Claude Code / Codex,但运行在 Web 页面上,通过 SSH 工具远程操作真实服务器。\n" + + "你的职责是:根据用户的自然语言任务描述,自主在目标服务器上排查故障、安装与配置软件、修复问题," + + "并通过 WebSocket 把每一步思考、执行的命令和输出实时反馈给用户。\n\n") + + // 目标主机(动态片段,但不属于记忆层) + if strings.TrimSpace(serverInfo) != "" { + sb.WriteString("【目标主机】\n" + serverInfo + "\n\n") + } + + // Layer 2: 安全规则 + sb.WriteString("【安全规则】\n") + sb.WriteString("1. 破坏性命令(rm -rf /、mkfs、dd 覆写磁盘等)被严格禁止,绝不执行。\n") + sb.WriteString("2. 高危操作(reboot、systemctl restart、kill、卸载软件等)需要用户确认后才能执行。\n") + sb.WriteString("3. 排障时优先使用只读命令(df/ps/cat/grep/tail/systemctl status/journalctl)收集信息...\n") + sb.WriteString("4. 不要执行任何你不确定后果的命令;宁可多问用户,也不盲目操作。\n\n") + + // Layer 3: 工具(动态生成) + if strings.TrimSpace(toolDescs) != "" { + sb.WriteString("【可用工具】\n" + toolDescs + "\n\n") + } + + // Layer 4: 工作方式(ReAct) + sb.WriteString("【工作方式(ReAct 循环)】\n") + sb.WriteString("1. 思考:分析当前状态,决定下一步...\n") + sb.WriteString("2. 行动:调用 ssh_execute 执行命令(一次只做关键的、可验证的一步)。\n") + sb.WriteString("3. 观察:仔细阅读命令输出(含 exit_code,0 表示成功)。\n") + sb.WriteString("4. 循环:如果命令失败或问题未解决,分析错误原因,换一种方法重试,直到真正解决。\n") + sb.WriteString("5. 收尾:问题解决后,用清晰的中文总结:问题根因、你做了什么、最终结果、后续建议。\n\n") + sb.WriteString("【重要】务必真正解决问题,而不是假装解决。每一步都要基于实际命令输出判断," + + "禁止编造不存在的输出。\n\n") + + // Layer 5: 记忆注入(动态) + if strings.TrimSpace(evolveContext) != "" { + sb.WriteString("【历史经验(来自自进化引擎)】\n" + evolveContext + "\n\n") + } + + // 输出要求 + sb.WriteString("【输出要求】使用简体中文,简洁专业。思考与命令穿插呈现,避免冗长空话。") + return sb.String() +} +``` + +> 与 opsxcli 的差异: +> - opsxcli 把五层拆为五个独立常量(`personaLayer` / `capabilityLayer` / `constraintLayer` / `fewShotLayer` / `memoryLayerPrefix`),aiops 合并为单个函数,更紧凑 +> - opsxcli 有独立的 `BuilderV2` 结构和 `NewBuilderV2WithMemory` 工厂,aiops 直接用函数(Web 场景下不需要构建器状态) +> - aiops 移除了独立的 fewShot 示例层,把 ReAct 工作方式说明合并到 Layer 4 + +## Layer 1: 角色定位 + +> **关键改写**:opsxcli 的角色是「opsxcli 智能运维超级助手」(CLI 工具语境);aiops 改写为「aiops 平台的 Agent CLI —— 企业级智能运维 Agent,类似 Claude Code / Codex,但运行在 Web 页面上」。 + +**设计要点**: + +- **明确类比**:用「类似 Claude Code / Codex」让用户和 LLM 都快速理解定位 +- **强调 Web 语境**:「运行在 Web 页面上」「通过 SSH 远程操作」「通过 WebSocket 反馈」 +- **明确职责边界**:排障、安装配置、修复 —— 不是「无所不能的 AI」 +- **不包含版本号**(与 opsxcli 一致) + +### 目标主机片段 + +`serverInfo` 由 controller 构建,注入到 Layer 1 之后: + +```go +serverInfo := fmt.Sprintf("主机名: %s | IP: %s:%d | 用户: %s | OS: %s", + ac.server.Hostname, ac.server.IP, ac.server.Port, ac.server.Username, ac.server.OS) +``` + +让 LLM 知道当前操作的是哪台主机、什么系统、用什么用户。 + +## Layer 2: 安全规则 + +四条硬规则: + +1. **破坏性命令严格禁止**(呼应代码层的黑名单硬拒) +2. **高危操作需用户确认**(呼应代码层的 ConfirmFn) +3. **排障优先只读命令**(引导 LLM 先收集信息再动手) +4. **不确定时不盲目操作**(宁可多问) + +> **注意**:Layer 2 是 Prompt 级别的「软约束引导」,与 `safety.Controller.Check` 的代码级「硬拦截」是不同层面的机制。两者互补:Prompt 引导 LLM 主动避免危险操作,代码层兜底防止 LLM 失控。 + +## Layer 3: 工具说明 + +由 `core.Agent.buildToolDescriptions()` 动态生成: + +```go +func (a *Agent) buildToolDescriptions() string { + var sb strings.Builder + for _, t := range a.registry.List() { + sb.WriteString(fmt.Sprintf("- %s: %s\n", t.Name(), t.Description())) + } + return sb.String() +} +``` + +**为什么动态生成?** + +- 工具集可扩展(实现 Tool 接口注册即可),硬编码 Prompt 会与代码脱节 +- 工具描述本身就是给 LLM 的「使用说明书」,由工具自己提供最准确 +- `ssh_execute` 的描述会包含具体的目标主机和用户信息(动态拼接) + +输出示例: + +``` +【可用工具】 +- ssh_execute: 在远程服务器 192.168.1.100(用户 root)上执行 shell 命令并返回合并的 stdout+stderr 输出与退出码... +- analyze_output: 分析一段(可能很长的)命令输出文本:统计行数、提取疑似错误/告警行... +``` + +## Layer 4: 工作方式(ReAct 范式) + +五步循环: + +1. **思考** —— 分析状态,决定下一步(产出 `thought` 文本) +2. **行动** —— 调用 `ssh_execute` 执行命令(一次只做关键的、可验证的一步) +3. **观察** —— 仔细阅读输出(含 `exit_code`,0 表示成功) +4. **循环** —— 失败则换法重试,直到真正解决 +5. **收尾** —— 清晰中文总结(根因、做了什么、结果、建议) + +**关键约束**(红线): + +> 务必真正解决问题,而不是假装解决。每一步都要基于实际命令输出判断,禁止编造不存在的输出。 + +这条约束非常重要 —— LLM 有「编造输出」的倾向,明确禁止 + 配合 `exit_code` 信号,能显著降低幻觉。 + +## Layer 5: 记忆注入(动态) + +由 `evolver.EvolverEngine.GetContextForPrompt(query)` 提供: + +```go +func (e *EvolverEngine) GetContextForPrompt(query string) string { + taskType := classifyTask(query, nil) + var sb strings.Builder + + // 同类任务的历史经验(优先成功) + if exps := e.mem.FindExperiences(taskType, 3); len(exps) > 0 { + sb.WriteString(fmt.Sprintf(" 【%s 类历史经验】\n", taskType)) + for _, exp := range exps { + mark := "成功" + if !exp.Success { mark = "未成功" } + sb.WriteString(fmt.Sprintf(" - (%s) %s\n", mark, exp.Hint)) + } + } + // 相关事实 + if facts := e.mem.RelevantFacts(query, 3); len(facts) > 0 { + sb.WriteString(" 【已知事实】\n") + for _, f := range facts { + sb.WriteString(fmt.Sprintf(" - %s\n", f.Fact)) + } + } + // 已知主机 + if hosts := e.mem.Hosts(); len(hosts) > 0 { + sb.WriteString(fmt.Sprintf(" 【已知主机】%s\n", strings.Join(hosts, ", "))) + } + return sb.String() +} +``` + +注入示例: + +``` +【历史经验(来自自进化引擎)】 + 【磁盘空间 类历史经验】 + - (成功) 完成「磁盘空间」类任务时,工具序列 ssh_execute → analyze_output 最终成功,可作为参考。 + - (成功) 对于磁盘满问题,先检查大日志文件再清理 + 【已知事实】 + - 生产 nginx 日志目录是 /var/log/nginx + 【已知主机】192.168.1.10, 192.168.1.11 +``` + +**设计要点**: + +- 按 `query` 分类后只注入**同类**经验,避免无关信息污染 +- 优先注入**成功**经验(`FindExperiences` 内部排序) +- 限制条数(经验 3 条、事实 3 条),控制 token 开销 + +> 与 opsxcli 的差异:opsxcli 把记忆层细分为 5.0 事实 / 5.1 经验 / 5.1.5 技能 / 5.2 环境 / 5.3 偏好;aiops 精简为 3 段(经验 / 事实 / 已知主机),剥离了用户偏好(Web 场景下偏好由安全模式等显式配置承载)和技能层(未实现自动 Skill 提炼)。 + +## 注入流程 + +``` +core.Agent.Run() + │ + ├─ evolveCtx = evolver.GetContextForPrompt(query) ← Layer 5 内容 + │ + ├─ toolDescs = buildToolDescriptions() ← Layer 3 内容 + │ + ├─ systemPrompt = prompt.BuildSystemPrompt( + │ serverInfo, ← Layer 1 目标主机片段 + │ toolDescs, ← Layer 3 + │ evolveCtx, ← Layer 5 + │ ) + │ + └─ messages = [{system: systemPrompt}, {user: query}] +``` + +## 为什么 Layer 1-4 是静态的? + +- 角色定位和工作方式在编译期确定 +- 安全规则不应该被动态修改(防止 Evolver 污染安全约束) +- 静态内容可以利用 LLM Provider 的 Prompt Caching(减少 token 消耗,部分 Provider 如 Anthropic 支持) +- 只有事实/经验需要动态更新 + +## 输出要求 + +最后一段统一约束输出风格: + +> 使用简体中文,简洁专业。思考与命令穿插呈现,避免冗长空话。 + +这与 opsxcli 一致 —— 中文运维语境的统一规范。 + +## 扩展指南 + +### 修改角色描述 + +直接修改 `BuildSystemPrompt` 中 Layer 1 的字符串。注意: + +- 不要包含版本号 +- 明确能力边界(不要吹成「无所不能」) +- 场景化描述优于抽象形容 + +### 增加新的 Prompt 层 + +1. 在 `BuildSystemPrompt` 中增加新的 `sb.WriteString(...)` 片段 +2. 在本文档更新架构图 +3. 若是动态层,需在 `Agent.Run` 中准备数据并传入 + +### 调整记忆注入策略 + +修改 `evolver.GetContextForPrompt`: + +- 增加新的记忆段(如「最近失败的任务」) +- 调整条数限制 +- 改进分类匹配逻辑 + +## 常见陷阱 + +| 陷阱 | 解决方案 | +|------|----------| +| Prompt 过长导致 token 超限 | Layer 1-4 精简 + Layer 5 按需注入 + 条数限制 | +| 记忆注入了无关经验 | `GetContextForPrompt` 按 `task_type` 匹配,不全量注入 | +| Layer 1-4 被 Evolver 污染 | 静态层硬编码在函数中,Evolver 只能影响 Layer 5 | +| LLM 编造命令输出 | Layer 4 显式禁止 + 输出附 `exit_code` 验证 | +| 角色描述含版本号 | 已清理,禁止再加版本号 | +| 工具描述与实际工具脱节 | Layer 3 由 Registry 动态生成,永远一致 | + +## 与 opsxcli 的差异总结 + +| 维度 | opsxcli | aiops | +|------|---------|-------| +| 代码组织 | 5 个独立常量 + BuilderV2 结构 | 单个 `BuildSystemPrompt` 函数 | +| 角色 | opsxcli 智能运维超级助手(CLI) | aiops Agent CLI(Web 版,类比 Claude Code) | +| fewShot 层 | 独立 Layer 4(运维场景示例) | 合并进 Layer 4 工作方式 | +| 记忆子层 | 5 个(事实/经验/技能/环境/偏好) | 3 个(经验/事实/已知主机) | +| 静态/动态分离 | 严格(BuilderV2.enableMemory 开关) | 严格(函数内 if 判断) | + +思想完全保留(五层架构 + 静态动态分离 + 记忆按需注入),实现更紧凑。 + +> 作者:Logan.Li diff --git a/docs/agent/08-Evolver自进化引擎.md b/docs/agent/08-Evolver自进化引擎.md new file mode 100644 index 0000000..63bf4d9 --- /dev/null +++ b/docs/agent/08-Evolver自进化引擎.md @@ -0,0 +1,515 @@ +# 08 - Evolver 自进化引擎 + +> 本文由 opsxcli 同名设计文档适配改写,结合 aiops Agent CLI 的 Web 实现(`backend/pkg/agentcli/evolver/`)。 +> **保留**:四层记忆架构(经验/环境/事实/程序)+ 进化循环思想(OBSERVE → ... → PREDICT)+ 异步非阻塞 + 优雅降级。 +> **精简**:opsxcli 13 步循环 + 3300 行精简为 6 步核心循环 + ~400 行;剥离反射评分、经验整合去重、自动 Skill 提炼等重型子流程。这是有意的精简适配,保留学习能力和注入机制。 + +## 包位置 + +``` +backend/pkg/agentcli/evolver/ +├── engine.go # 进化引擎核心(精简进化循环) +└── memory.go # 四层记忆 + JSONL/JSON 文件存储 +``` + +## 设计哲学 + +> **让 Agent 越用越聪明** —— 每次任务完成后自动学习经验,优化后续表现。 + +Evolver 借鉴 opsxcli 的设计理念:通过经验积累、LLM 反射分析和环境自适应,实现 Agent 的持续自我优化。用户使用越多,Agent 越了解服务器环境、最佳工具组合和已知事实。 + +**精简原则**:保留「学习 → 存储 → 注入」的核心闭环,剥离重型分析(评分、整合、Skill 提炼)。精简后的 Evolver 仍是真正的自学习引擎,而非装饰品 —— 它真的会把上次任务的经验存下来,并在下次同类任务时注入 Prompt。 + +## 核心数据结构 + +### EvolverEngine + +```go +// backend/pkg/agentcli/evolver/engine.go + +type EvolverEngine struct { + mem *Memories // 四层记忆集合 + llm llm.Client // 可选,用于提炼经验/事实(nil 时退化为规则学习) + memDir string // 记忆目录(默认 data/agentcli) +} +``` + +> 与 opsxcli 的差异:剥离了 `minSteps` / `simpleThresh` / `moderateThresh` / `onProgress` 等配置字段;剥离 `reflectionClient` 独立接口(直接用 `llm.Client`)。 + +### TaskExecution(任务执行记录) + +每次任务完成后由 `core.Agent.triggerEvolve` 构造: + +```go +type TaskExecution struct { + Query string // 用户原始查询 + ToolCalls []ToolCallRecord // 工具调用序列 + TotalSteps int // 总迭代步数 + TotalTokens int // 消耗的 token 数 + Duration time.Duration // 执行耗时 + Success bool // 是否成功 + FinalAnswer string // 最终回答 + Timestamp time.Time // 执行时间 + ServerHost string // 目标主机(用于环境记忆) +} +``` + +### ToolCallRecord / EvolveResult + +```go +type ToolCallRecord struct { + ToolName string + Args map[string]interface{} + Output string + Duration time.Duration + Success bool + RiskLevel int +} + +type EvolveResult struct { + TaskType string // 自动分类的任务类型 + LearnedHint string // 学到的经验提示 + ToolSequence []string // 工具序列 + ExperienceAdded bool // 是否新增经验 + Consolidated bool // 是否触发整合(aiops 版保留字段,未实现) +} +``` + +## 精简进化循环(6 步) + +opsxcli 是 13 步(OBSERVE → EXTRACT → REFLECT → SCORE → COMPARE → LEARN → STORE → CONSOLIDATE → PREDICT → ADAPT → MEMORIZE → DISTILL → FEEDBACK),aiops 精简为 6 步核心: + +``` +┌─────────────────────────────────────────────┐ +│ Evolve 6 步核心循环 │ +├─────────────────────────────────────────────┤ +│ │ +│ Step 1: OBSERVE ── 观察工具序列 │ +│ ↓ 提取 ToolSequence(如 ssh → analyze) │ +│ │ +│ Step 2: CLASSIFY ── 任务类型分类 │ +│ ↓ 关键词规则匹配(11 类,无需 LLM) │ +│ │ +│ Step 3: LEARN ── 生成经验提示 │ +│ ↓ LLM 优先提炼一句经验,失败降级规则生成 │ +│ │ +│ Step 4: STORE ── 存入经验 + 环境记忆 │ +│ ↓ experience.jsonl + environment.json │ +│ │ +│ Step 5: MEMORIZE ── LLM 提炼持久事实 │ +│ ↓ 异步,从 FinalAnswer 提炼事实存 factual │ +│ │ +│ Step 6: PREDICT ── 注入下次 Prompt │ +│ ↓ GetContextForPrompt(下次任务开始时) │ +│ │ +└─────────────────────────────────────────────┘ +``` + +### 各步骤详解 + +#### Step 1: OBSERVE(观察) + +提取工具序列: + +```go +for _, tc := range exec.ToolCalls { + result.ToolSequence = append(result.ToolSequence, tc.ToolName) +} +``` + +输出如 `["ssh_execute", "analyze_output", "ssh_execute"]`。 + +#### Step 2: CLASSIFY(任务分类) + +关键词规则匹配(不调 LLM,零成本): + +```go +func classifyTask(query string, calls []ToolCallRecord) string { + text := strings.ToLower(query) + for _, c := range calls { + if cmd, ok := c.Args["command"].(string); ok { + text += " " + strings.ToLower(cmd) + } + } + // 11 类规则匹配 + rules := []struct{ typ string; kws []string }{ + {"磁盘空间", []string{"disk", "空间", "df", "du", "inode", "磁盘", "满了"}}, + {"网络", []string{"network", "网络", "ping", "netstat", "ss ", "端口", "tcp", "dns"}}, + {"服务管理", []string{"service", "服务", "systemctl", "nginx", "mysql", "redis", "restart"}}, + {"软件安装", []string{"install", "安装", "yum", "apt", "docker", "部署", "deploy"}}, + {"进程排查", []string{"process", "进程", "ps ", "kill", "cpu", "top", "卡死"}}, + {"内存排查", []string{"memory", "内存", "mem", "free", "oom", "swap"}}, + {"日志排查", []string{"log", "日志", "journalctl", "tail", "grep"}}, + {"权限问题", []string{"permission", "权限", "chmod", "chown", "denied", "拒绝"}}, + {"性能调优", []string{"performance", "性能", "负载", "load", "慢", "slow"}}, + {"配置管理", []string{"config", "配置", "conf", "vim", "sed"}}, + } + // ... 命中即返回,否则 "通用运维" +} +``` + +**设计要点**:把 query + 实际命令拼起来分类,比单看 query 更准(用户问「检查 nginx」,实际命令可能是 `systemctl status nginx`,分类为「服务管理」更合理)。 + +#### Step 3: LEARN(生成经验提示) + +LLM 优先,失败降级规则: + +```go +func (e *EvolverEngine) learnHint(ctx context.Context, exec *TaskExecution, taskType string) string { + tools := strings.Join(uniqueStrings(resultToolNames(exec.ToolCalls)), " → ") + + // 尝试用 LLM 提炼一句经验 + if e.llm != nil { + if hint := e.llmLearnHint(ctx, exec, taskType, tools, exec.Success); hint != "" { + return hint + } + } + // 降级:规则生成 + if exec.Success { + return fmt.Sprintf("完成「%s」类任务时,工具序列 %s 最终成功,可作为参考。", taskType, tools) + } + return fmt.Sprintf("尝试「%s」类任务时,工具序列 %s 未成功,下次可换用不同命令或思路。", taskType, tools) +} +``` + +LLM 提炼的 prompt(中文): + +``` +你是运维经验提炼器。根据以下任务执行记录,用一句中文(不超过80字)提炼经验提示, +说明下次做同类任务的关键要点或推荐工具顺序。只返回经验文本,不要多余解释。 +任务: +工具序列: +结果: 成功/失败 +``` + +**降级策略**:LLM 不可用或超时 → 用规则模板生成(仍然有价值,只是不够精炼)。 + +#### Step 4: STORE(存储经验 + 环境记忆) + +```go +exp := ExperienceEntry{ + TaskType: result.TaskType, + Query: truncate(exec.Query, 200), + Hint: result.LearnedHint, + ToolSequence: result.ToolSequence, + Success: exec.Success, + Timestamp: exec.Timestamp.Unix(), +} +e.mem.AddExperience(exp) +if exec.ServerHost != "" { + e.mem.AddHost(exec.ServerHost) // 环境记忆 +} +``` + +写入 `data/agentcli/experience.jsonl`(追加)和 `environment.json`(去重合并)。 + +#### Step 5: MEMORIZE(LLM 提炼持久事实) + +仅在任务成功 + 有 FinalAnswer + LLM 可用时触发,**异步执行不阻塞**: + +```go +if e.llm != nil && exec.Success && exec.FinalAnswer != "" { + go e.memorizeFact(ctx, exec) // 异步 +} +``` + +LLM 提炼 prompt: + +``` +从以下运维任务记录中,提炼 1 条值得长期记住的事实(如某主机的固定配置、已知问题、约定)。 +如果没有明显可沉淀的事实,回复「无」。只返回事实本身,一句话。 +任务: +结论: +``` + +提炼出的事实存入 `factual.jsonl`。 + +> 与 opsxcli 的差异:opsxcli 的 MEMORIZE 从执行过程中提取结构化事实(key-value),aiops 简化为让 LLM 自由提炼一句事实。更简单,覆盖面更广。 + +#### Step 6: PREDICT(注入下次 Prompt) + +`GetContextForPrompt(query)` 在下次任务开始时被 `core.Agent.Run` 调用: + +```go +evolveCtx := a.evolver.GetContextForPrompt(query) +systemPrompt := prompt.BuildSystemPrompt(serverInfo, toolDescs, evolveCtx) +``` + +注入内容会被放入 Prompt Layer 5(详见 [07 - Prompt 构建](07-Prompt构建与记忆注入.md))。 + +## 四层记忆系统 + +aiops 保留 opsxcli 的四层记忆架构: + +``` +┌──────────────────────────────────────────────────────────────┐ +│ 四层记忆架构 │ +├──────────────────────────────────────────────────────────────┤ +│ │ +│ Layer 1: ExperienceMemory(经验层) │ +│ 任务完成后的经验,按 task_type 分类 │ +│ 存储: data/agentcli/experience.jsonl │ +│ │ +│ Layer 2: EnvironmentMemory(环境层) │ +│ 已知主机列表 │ +│ 存储: data/agentcli/environment.json │ +│ │ +│ Layer 3: FactualMemory(事实层) │ +│ LLM 提炼的持久事实 │ +│ 存储: data/agentcli/factual.jsonl │ +│ │ +│ Layer 4: ProceduralMemory(程序层) │ +│ 程序性知识(接口位,未实现自动 Skill 提炼) │ +│ 存储: data/agentcli/procedural.jsonl │ +│ │ +└──────────────────────────────────────────────────────────────┘ +``` + +### Memories 结构 + +```go +// backend/pkg/agentcli/evolver/memory.go + +type Memories struct { + dir string + mu sync.Mutex + + expPath string // experience.jsonl + facPath string // factual.jsonl + sklPath string // procedural.jsonl + envPath string // environment.json +} +``` + +### ExperienceEntry / FactEntry / SkillEntry + +```go +type ExperienceEntry struct { + TaskType string `json:"task_type"` + Query string `json:"query"` + Hint string `json:"hint"` + ToolSequence []string `json:"tool_sequence"` + Success bool `json:"success"` + Timestamp int64 `json:"timestamp"` +} + +type FactEntry struct { + Fact string `json:"fact"` + Source string `json:"source"` + Timestamp int64 `json:"timestamp"` +} + +type SkillEntry struct { + Name string `json:"name"` + When string `json:"when"` + Steps []string `json:"steps"` + Timestamp int64 `json:"timestamp"` +} +``` + +> 与 opsxcli 的差异: +> - opsxcli 的 `Experience` 有 `SuccessRate` / `UsageCount` / `Tags`(用于整合去重),aiops 简化为 `Success` 布尔(剥离整合逻辑) +> - opsxcli 的 `FactEntry` 是 key-value(`Key` / `Value` / `Category`),aiops 简化为单字段 `Fact`(一句话) +> - opsxcli 的 `SkillEntry` 有完整的 ID/Version/Triggers/Pitfalls/SuccessRate,aiops 简化为 Name/When/Steps(接口位,未实际使用) + +### 存储格式 + +#### experience.jsonl(追加写入) + +```json +{"task_type":"磁盘空间","query":"检查磁盘空间","hint":"对于磁盘满问题,先检查大日志文件再清理","tool_sequence":["ssh_execute","analyze_output"],"success":true,"timestamp":1748000000} +``` + +#### environment.json(整体读写) + +```json +{ + "hosts": ["192.168.1.10", "192.168.1.11"] +} +``` + +#### factual.jsonl(追加写入) + +```json +{"fact":"生产 nginx 日志目录是 /var/log/nginx","source":"检查 nginx 启动失败","timestamp":1748000000} +``` + +### 核心查询方法 + +| 方法 | 说明 | +|------|------| +| `AddExperience(e)` | 追加经验 | +| `FindExperiences(taskType, n)` | 按类型查找,**优先成功**,取最近 n 条 | +| `AddFact(f)` | 追加事实 | +| `RelevantFacts(query, n)` | 按 query 关键词匹配事实(双向 Contains) | +| `AddHost(host)` | 记录已知主机(去重) | +| `Hosts()` | 已知主机列表 | +| `CountStats()` | 各层条目数(供审计展示) | + +**`FindExperiences` 的排序策略**: + +```go +// 优先成功经验,按时间取最近 n 条 +successFirst := ... // 过滤 success=true +others := ... // 过滤 success=false +out := append(pick(successFirst), pick(others)...) +``` + +让 LLM 优先看到成功的经验。 + +## 与 Agent 的集成 + +### 初始化与优雅降级 + +在 `agentcli.NewEngine` 中: + +```go +ev, err := evolver.NewEvolverEngine(coreCfg.MemoryDir, client) +if err != nil { + ev = nil // 降级:无自学习,不影响主流程 +} +agent := core.NewAgent(client, registry, coreCfg, sc, ev) +``` + +**降级场景**: + +- 记忆目录无法创建(权限问题)→ `ev = nil` +- 此时 Agent 正常运行,只是 Layer 5 为空 + +### 异步进化流程 + +``` +用户查询 → core.Agent.Run() ReAct 循环 → 任务完成 + ↓ + triggerEvolve()(异步 goroutine) + ↓ + 30s 超时上下文 + panic 恢复 + ↓ + EvolverEngine.Evolve() + ↓ + 存入 experience.jsonl / factual.jsonl + ↓ + 下次 Run() → GetContextForPrompt() → 注入 Layer 5 +``` + +**三层保护**: + +1. **后台 goroutine**:不阻塞用户,答案立即返回 +2. **30 秒超时**:`context.WithTimeout(ctx, 30*time.Second)` +3. **panic 恢复**:`defer func() { _ = recover() }()` + +### 经验注入 + +下次任务开始时,`GetContextForPrompt(query)` 按 `task_type` 取相关经验注入 Layer 5: + +``` +【历史经验(来自自进化引擎)】 + 【磁盘空间 类历史经验】 + - (成功) 对于磁盘满问题,先检查大日志文件再清理 + - (成功) 完成「磁盘空间」类任务时,工具序列 ssh_execute → analyze_output 最终成功 + 【已知事实】 + - 生产 nginx 日志目录是 /var/log/nginx + 【已知主机】192.168.1.10, 192.168.1.11 +``` + +## 任务分类系统(11 类) + +`classifyTask` 把用户查询 + 实际命令分类为 11 类: + +| 任务类型 | 关键词(部分) | +|----------|---------------| +| 磁盘空间 | disk / 空间 / df / du / inode / 磁盘 / 满了 | +| 网络 | network / 网络 / ping / netstat / ss / 端口 / tcp / dns | +| 服务管理 | service / 服务 / systemctl / nginx / mysql / redis / restart | +| 软件安装 | install / 安装 / yum / apt / docker / 部署 | +| 进程排查 | process / 进程 / ps / kill / cpu / top / 卡死 | +| 内存排查 | memory / 内存 / mem / free / oom / swap | +| 日志排查 | log / 日志 / journalctl / tail / grep | +| 权限问题 | permission / 权限 / chmod / chown / denied / 拒绝 | +| 性能调优 | performance / 性能 / 负载 / load / 慢 / slow | +| 配置管理 | config / 配置 / conf / vim / sed | +| 通用运维 | (兜底) | + +## 创建方式 + +```go +// 基础引擎(无 LLM 提炼,纯规则学习) +engine, _ := evolver.NewEvolverEngine("data/agentcli", nil) + +// 带 LLM 提炼的引擎(推荐) +engine, _ := evolver.NewEvolverEngine("data/agentcli", llmClient) +``` + +> 与 opsxcli 的差异:opsxcli 有 `NewEvolverEngine` 和 `NewEvolverEngineWithLLM` 两个工厂;aiops 合并为一个,通过 `client` 参数是否为 nil 区分。 + +## 统计与监控 + +```go +func (e *EvolverEngine) GetStats() map[string]interface{} { + return map[string]interface{}{ + "enabled": true, + "memory": e.mem.CountStats(), // {experience: N, factual: N, skill: N, hosts: N} + } +} +``` + +由 `Engine.EvolveStats()` 暴露,供审计展示。 + +## 精简决策说明 + +### 剥离了什么 + +| opsxcli 步骤 | aiops 处理 | 原因 | +|--------------|-----------|------| +| Step 2.5 REFLECT(LLM 反射分析) | 合并进 Step 3 LEARN | 单次 LLM 调用同时做「分析 + 提炼」更高效 | +| Step 3 SCORE(效率评分) | 剥离 | 评分用于整合决策,aiops 不做整合 | +| Step 4 COMPARE(历史对比) | 简化为 `FindExperiences` 查询 | 不需要复杂相似度计算 | +| Step 7 CONSOLIDATE(经验整合) | 剥离 | JSONL 追加 + 查询时去重足够,不需要主动合并 | +| Step 9.7 DISTILL(自动 Skill 提炼) | 剥离 | Skill 提炼需要复杂条件判断 + 文件管理,价值/复杂比不高 | +| Step 10 FEEDBACK(用户反馈消息) | 剥离 | Web 场景下经验学习对用户透明,不需要弹消息 | + +### 保留了什么 + +- **四层记忆架构** —— 思想完整保留,只是程序层未实际使用 +- **OBSERVE → LEARN → STORE → MEMORIZE → PREDICT 闭环** —— 真正的学习与注入 +- **LLM 提炼 + 规则降级** —— 学习质量有保障 +- **异步非阻塞 + 优雅降级** —— 工程可靠性 +- **按 task_type 分类注入** —— 避免无关污染 + +精简后的 Evolver 仍是「真正的自学习引擎」,而非装饰品。 + +## 设计亮点 + +1. **异步非阻塞**:进化在后台 goroutine,不增加用户等待 +2. **优雅降级**:LLM 失败退化为规则学习,Evolver 初始化失败 Agent 正常运行 +3. **四层记忆**:经验 + 环境 + 事实 + 程序,覆盖从瞬时到持久的认知链路 +4. **会话级反馈**:上次任务的经验立即注入下次 Prompt +5. **线程安全**:`Memories.mu sync.Mutex` 保护文件读写 +6. **零 DB 依赖**:纯文件存储,与引擎库「零业务依赖」原则一致 + +## 常见陷阱 + +| 陷阱 | 解决方案 | +|------|----------| +| 记忆目录权限不足 | `NewEvolverEngine` 返回 error,`NewEngine` 把 ev 设为 nil 降级 | +| LLM 提炼经验超时 | 30s 超时 + 降级为规则模板 | +| 经验注入污染上下文 | 按 `task_type` 匹配 + 条数限制(3 条) | +| 事实记忆累积过多无关项 | LLM prompt 明确「无明显事实回复『无』」+ 不写入 | +| 并发写入 JSONL 冲突 | `Memories.mu sync.Mutex` 串行化写入 | +| 程序层未实现却被引用 | 当前版本 `procedural.jsonl` 仅作为接口位,`GetContextForPrompt` 不读它 | + +## 与 opsxcli 的差异总结 + +| 维度 | opsxcli | aiops | +|------|---------|-------| +| 进化步骤 | 13 步 | 6 步 | +| 代码规模 | ~3300 行 | ~400 行 | +| 记忆查询 | 复杂相似度 + 整合去重 | 简单 task_type 匹配 + 时间倒序 | +| 经验字段 | SuccessRate / UsageCount / Tags | Success(布尔) | +| Skill 提炼 | 自动(Step 9.7) | 未实现(接口位) | +| 反射分析 | 独立 Step + JSON 结构 | 合并进 LEARN | +| 用户反馈 | Step 10 弹消息 | 剥离(Web 透明) | + +**核心思想保留度**:四层记忆 ✓ / 进化闭环 ✓ / 异步降级 ✓ / 经验注入 ✓。精简的是重型分析,不是学习能力本身。 + +> 作者:Logan.Li diff --git a/docs/agent/09-安全系统与工具注册.md b/docs/agent/09-安全系统与工具注册.md new file mode 100644 index 0000000..79740a7 --- /dev/null +++ b/docs/agent/09-安全系统与工具注册.md @@ -0,0 +1,468 @@ +# 09 - 安全系统与工具注册 + +> 本文由 opsxcli 同名设计文档适配改写,结合 aiops Agent CLI 的 Web 实现(`backend/pkg/agentcli/safety/safety.go` + `tools/registry.go` + `tools/risk.go`)。 +> **保留**:三级安全模式 / 五级风险 / 危险命令黑名单 / AssessBashRisk 动态评估 / Tool 接口 + Registry。 +> **适配**:确认机制从 CLI 终端 `bufio.Reader` 改为 WebSocket `confirm` 事件 + 前端 `useConfirm()` 弹框;审计日志从文件改为 DB 落库。 + +## 设计理念 + +安全系统是运维 Agent 的生命线。aiops Agent CLI 安全系统遵循两个核心原则: + +1. **默认安全**:balanced 模式下,high+ 操作需要确认,覆盖 95% 日常运维 +2. **可审计**:每次工具调用都通过 EventCallback 落库,事后可追溯 + +工具注册系统遵循**工具化思维**:万物皆 Tool,实现接口即可扩展。 + +--- + +## Part 1: 安全系统 + +### 架构总览 + +``` +┌────────────────────────────────────────────────────┐ +│ safety.Controller │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ │ +│ │ risk.go │ │ controller.go │ │ +│ │ (在 tools 包)│ │ (在 safety 包)│ │ +│ │ 黑名单 + 分级 │ │ 阈值 + ConfirmFn│ │ +│ └──────────────┘ └──────────────┘ │ +│ │ +│ Safety Modes: │ +│ strict → medium+ 确认 │ +│ balanced → high+ 确认(默认) │ +│ permissive → critical 仅处理 │ +└────────────────────────────────────────────────────┘ +``` + +> 与 opsxcli 的差异:opsxcli 把风险评估和审计都放在 `safety/` 包;aiops 为避免 `tools` ↔ `safety` 循环依赖,把 `risk.go`(黑名单 + AssessBashRisk)置于 `tools` 包,由 `ssh_execute` 的 `DynamicRiskTool` 直接调用;`safety` 包只做「是否需确认」的策略。 + +### Controller 结构 + +```go +// backend/pkg/agentcli/safety/safety.go + +type Controller struct { + mode Mode // 当前安全模式 + confirmFn ConfirmFn // 确认回调(controller 注入:推 WS confirm + 阻塞等回传) + autoApprove bool // 自动批准(仅测试用,生产禁用) +} +``` + +> 与 opsxcli 的差异:剥离 `reader *bufio.Reader`(CLI 终端交互)和 `history []ExecutionRecord`(内存审计历史),改为通过 EventCallback 落库 DB;剥离 `auditLog *AuditLogWriter`(文件审计)。 + +### ConfirmFn —— Web 版的确认机制 + +```go +type ConfirmFn func(toolName string, args map[string]interface{}, risk tools.RiskLevel) (bool, error) +``` + +controller 实现的 `confirmFn`: + +```go +// backend/controller/agent_cli.go + +func (ac *agentConn) confirmFn(toolName string, args map[string]interface{}, risk tools.RiskLevel) (bool, error) { + confirmID := fmt.Sprintf("cf-%d", time.Now().UnixNano()) + cmd, _ := args["command"].(string) + // 1. 推 WS confirm 事件(前端 useConfirm 弹框) + ac.write(wsEvent{ + Type: "confirm", ConfirmID: confirmID, + ToolName: toolName, Command: cmd, Risk: risk.Display(), ToolArgs: args, + }) + + // 2. 注册 channel 等待前端回传 + ch := make(chan bool, 1) + ac.cfMu.Lock() + ac.confirms[confirmID] = ch + ac.cfMu.Unlock() + + // 3. 阻塞等待(5 分钟超时) + select { + case approved := <-ch: + return approved, nil + case <-time.After(5 * time.Minute): + ac.delConfirm(confirmID) + return false, fmt.Errorf("确认超时") + } +} +``` + +前端处理(`AgentCLI.tsx`): + +```tsx +case 'confirm': { + const ok = await confirm({ + title: '确认执行高危命令', + description: `即将执行:${ev.command}`, + confirmText: '确认执行', + }); + wsRef.current?.send(JSON.stringify({ + type: ok ? 'confirm' : 'deny', + confirm_id: ev.confirm_id, + })); + break; +} +``` + +这是「Web 版 CLI 终端确认」的完整替代 —— 用户在浏览器看到弹框,点确认/拒绝,结果通过 WebSocket 回传解锁引擎。 + +### 三级安全模式 + +```go +const ( + ModeStrict Mode = "strict" // medium+ 需确认 + ModeBalanced Mode = "balanced" // high+ 需确认(默认) + ModePermissive Mode = "permissive" // 仅 critical 处理 +) +``` + +| 模式 | 需要确认的等级 | 适用场景 | +|------|---------------|----------| +| `strict` | medium 及以上 | 生产环境、关键主机、新手操作 | +| `balanced`(默认) | high 及以上 | 日常运维 | +| `permissive` | 仅 critical | 测试环境、自动化脚本 | + +阈值映射: + +```go +func (c *Controller) riskThreshold() tools.RiskLevel { + switch c.mode { + case ModeStrict: return tools.RiskMedium + case ModePermissive: return tools.RiskCritical + default: return tools.RiskHigh + } +} +``` + +### 五级风险评估 + +```go +// backend/pkg/agentcli/tools/registry.go + +const ( + RiskSafe RiskLevel = iota // 0 - 只读(cat, grep, df, ps) + RiskLow // 1 - 低风险(mkdir, touch) + RiskMedium // 2 - 中风险(chmod, 文件修改) + RiskHigh // 3 - 高风险(ssh_execute, 服务控制) + RiskCritical // 4 - 危险(rm -rf /, mkfs, dd, 数据删除) +) +``` + +每级都有中文显示(`Display()`): + +```go +case RiskSafe: return "安全" +case RiskLow: return "低风险" +case RiskMedium: return "中风险" +case RiskHigh: return "高风险" +case RiskCritical: return "危险" +``` + +前端在 `tool_call` 事件中拿到 `risk` 字段,渲染为风险标签。 + +### 危险命令黑名单(硬红线) + +```go +// backend/pkg/agentcli/tools/risk.go + +var dangerousCommands = []string{ + // 文件系统毁灭性操作 + "rm -rf /", "rm -rf /*", "rm -rf /bin", "rm -rf /boot", "rm -rf /etc", + "rm -rf /home", "rm -rf /lib", "rm -rf /lib64", "rm -rf /proc", "rm -rf /root", + "rm -rf /sbin", "rm -rf /sys", "rm -rf /usr", "rm -rf /var", + // 磁盘格式化 + "mkfs", "mkfs.ext", "mkfs.xfs", "mkfs.btrfs", "mkfs.vfat", + // 磁盘覆写 + "dd if=/dev/zero", "dd if=/dev/random", "dd if=/dev/urandom", + // Fork bomb + ":(){ :|:& };:", ":() { : | : & }; :", + // 设备重定向 + "> /dev/sda", "> /dev/sdb", "> /dev/sdc", "> /dev/hda", + // 数据销毁 + "mv / /dev/null", "mv /* /dev/null", + // 权限破坏 + "chmod -R 000 /", "chmod -R 777 /", + // 服务自毁 + "systemctl stop ssh", "systemctl disable ssh", "kill -9 1", + // 防火墙清空 + "iptables -F", "iptables --flush", +} +``` + +**特殊检测**:`rm -rf` + 根目录路径(即使不在黑名单字面中): + +```go +if strings.HasPrefix(lower, "rm") && strings.Contains(lower, "-rf") && containsRootPath(lower) { + return true, "rm -rf 包含根目录路径" +} +``` + +`containsRootPath` 检测 `/boot`、`/etc`、`/usr`、`/var` 等系统目录。 + +### Check 策略 + +```go +func (c *Controller) Check(tool tools.Tool, args map[string]interface{}) (bool, error) { + risk := assessToolRisk(tool, args) + + // 1. 黑名单硬红线:绝不允许(不可确认放行) + if risk >= tools.RiskCritical { + return false, nil + } + + // 2. 未达阈值或自动批准:直接放行 + if c.autoApprove || risk < c.riskThreshold() { + return true, nil + } + + // 3. 达阈值:请求用户确认 + if c.confirmFn == nil { + return false, nil // 无确认回调时保守拒绝(Web 场景应始终注入 confirmFn) + } + return c.confirmFn(tool.Name(), args, risk) +} +``` + +**三层策略**: + +1. **硬红线**(Critical):黑名单命令直接拒绝,**不可被确认放行** +2. **阈值放行**:低于模式阈值的操作直接执行 +3. **用户确认**(达阈值):调 `confirmFn` 推 WS 弹框等用户决定 + +### AssessBashRisk 动态评估 + +```go +func AssessBashRisk(command string) RiskLevel { + // 1. 黑名单精确匹配 → Critical + if isDangerous, _ := IsDangerousCommand(command); isDangerous { + return RiskCritical + } + // 2. 纯只读命令 → Safe + if isReadOnlyCommand(lower) { + return RiskSafe + } + // 3. 组合命令(&& || | ;)→ 取子命令最高风险 + if isCompound(lower) { + return assessCompoundCommand(lower) + } + // 4. 单命令分类 + isSudo := strings.HasPrefix(lower, "sudo ") + return assessSingleCommand(strings.TrimPrefix(lower, "sudo "), isSudo) +} +``` + +### 命令分类表 + +| 类别 | 命令示例 | 风险等级 | +|------|---------|---------| +| 只读(Safe) | cat, grep, awk, sed, head, tail, ps, top, df, du, free, uptime, uname, hostname, ifconfig, ip addr, netstat, ss, ls, pwd, who, w, last, vmstat, iostat, lsof, find, which, file, stat, date, echo, id, whoami, dmesg, systemctl status, journalctl, lsblk, mount | Safe | +| 修改(Low/Medium) | cp, mv, mkdir, rmdir, touch, chmod, chown, chgrp, ln, tar, gzip, gunzip, zip, unzip, rsync, tee | Low(sudo 时 Medium) | +| 高危(Medium/High) | rm, dd, shutdown, reboot, halt, poweroff, init, systemctl restart/stop/start, kill, killall, pkill, userdel, groupdel, fdisk, parted, mkfs | Medium(sudo 时 High) | +| 危险(Critical) | rm -rf /, mkfs, dd if=/dev/zero, fork bomb, > /dev/sda, chmod -R 000 / | Critical(硬拒) | + +### sudo 提升 + +`sudo` 会让风险等级 +1(最高到 Critical 前): + +```go +// assessSingleCommand +if isSudo { + return RiskHigh // 高危命令 + sudo → High +} +return RiskMedium // 高危命令无 sudo → Medium +``` + +### 组合命令取最高 + +```go +// assessCompoundCommand: 拆分 && || | ; 后取子命令最高风险 +// "df -h && rm /tmp/file" → max(Safe, Medium) = Medium +// "cat /etc/passwd; rm -rf /tmp" → max(Safe, Low) = Low +``` + +### 审计落库(替代文件审计) + +> opsxcli 把审计写到 `~/.opsxcli/audit/audit.log`;aiops 直接走 DB(`agent_cli_messages` 表),多用户场景下可按 user_id / session_id 查询。 + +controller 的 `onEvent` 回调把每个 `tool_call` / `tool_result` 落库: + +```go +case core.EventToolCall: + argsJSON, _ := json.Marshal(e.ToolArgs) + ac.persistMsg(sid, e.Round, "tool_call", "tool", "", + e.ToolName, string(argsJSON), "", e.Risk, false, "", 0) +case core.EventToolResult: + ac.persistMsg(sid, e.Round, "tool_result", "tool", e.Output, + e.ToolName, "", "", "", e.Success, e.Duration, 0) +``` + +每条记录包含:工具名、参数 JSON、风险等级、成败、耗时、轮次、会话 ID、用户 ID(通过 session 关联)。前端「会话历史」页面可完整回放。 + +--- + +## Part 2: 工具注册系统 + +### Registry 注册中心 + +```go +// backend/pkg/agentcli/tools/registry.go + +type Registry struct { + tools map[string]Tool + mu sync.RWMutex +} +``` + +核心方法: + +| 方法 | 说明 | +|------|------| +| `NewRegistry()` | 创建空注册中心 | +| `Register(tool)` | 注册工具(线程安全) | +| `Get(name)` | 按名获取工具(LLM 调用时按名查找) | +| `List()` | 列出所有工具 | +| `ToLLMTools()` | 转换为 OpenAI function calling 格式 | + +### Tool 接口(5 方法) + +```go +type Tool interface { + Name() string + Description() string + Parameters() map[string]interface{} // JSON Schema + RiskLevel() RiskLevel + Execute(ctx context.Context, args map[string]interface{}) (*Result, error) +} +``` + +### DynamicRiskTool 扩展接口 + +```go +type DynamicRiskTool interface { + Tool + RiskLevelForArgs(args map[string]interface{}) RiskLevel +} +``` + +安全控制器 `Check` 时优先调用动态评估: + +```go +func assessToolRisk(tool tools.Tool, args map[string]interface{}) tools.RiskLevel { + if dt, ok := tool.(tools.DynamicRiskTool); ok { + return dt.RiskLevelForArgs(args) // 动态优先 + } + return tool.RiskLevel() +} +``` + +### Result 结构 + +```go +type Result struct { + Success bool `json:"success"` + Output string `json:"output"` + Error string `json:"error,omitempty"` +} +``` + +### 参数解析辅助 + +```go +func StringParam(args map[string]interface{}, key string) string +func IntParam(args map[string]interface{}, key string) int // 兼容 int/float64/int64 +``` + +LLM 返回的 JSON 参数反序列化后,数字可能是 `float64`(JSON 原生),`IntParam` 统一处理。 + +### 已注册工具一览 + +| 工具 | 静态风险 | 动态风险 | 功能 | +|------|---------|---------|------| +| `ssh_execute` | High | Safe~Critical | 远程命令执行(DynamicRiskTool) | +| `analyze_output` | Safe | — | 输出分析(纯文本,只读) | + +详细设计见 [05 - Builtin 内置工具](05-Builtin内置命令.md)。 + +> 与 opsxcli 的差异:剥离了 `local_bash` / `scp_transfer` / `file_read` / `file_search` 四个工具;剥离 SSH 连接池(aiops 直接用 `pkg/remoteexec`,无连接池需求);剥离 `Closer` 接口(Web 工具无资源需释放)。 + +## 扩展指南 + +### 添加新工具 + +```go +// 1. 实现 Tool 接口(必要时实现 DynamicRiskTool) +type MyTool struct{} +func (t *MyTool) Name() string { return "my_tool" } +func (t *MyTool) Description() string { return "我的工具" } +func (t *MyTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "target": map[string]interface{}{"type": "string", "description": "目标"}, + }, + "required": []string{"target"}, + } +} +func (t *MyTool) RiskLevel() tools.RiskLevel { return tools.RiskMedium } +func (t *MyTool) Execute(ctx context.Context, args map[string]interface{}) (*tools.Result, error) { + // 实现 + return &tools.Result{Success: true, Output: "done"}, nil +} + +// 2. 在 agentcli.NewEngine 中注册 +registry.Register(&MyTool{}) +``` + +### 扩展危险命令黑名单 + +修改 `tools/risk.go` 的 `dangerousCommands` 切片: + +```go +var dangerousCommands = []string{ + // ... 现有 + "your-critical-command", +} +``` + +### 自定义确认逻辑 + +controller 的 `confirmFn` 可对接企业审批系统(如先走工单再确认): + +```go +engine.SetConfirmFn(func(toolName string, args map[string]interface{}, risk tools.RiskLevel) (bool, error) { + // 1. 推 WS 弹框让用户初步确认 + // 2. 高危场景额外对接审批工单系统 + // 3. 返回最终批准结果 +}) +``` + +## 常见陷阱 + +| 陷阱 | 解决方案 | +|------|----------| +| 忘记设置 RiskLevel 导致误判 | 默认按接口返回值,高危工具必须显式声明 | +| 高危工具未实现 DynamicRiskTool | 静态等级无法反映参数差异,必须实现动态评估 | +| 黑名单只看前缀匹配 | `IsDangerousCommand` 同时做精确匹配 + `rm -rf` 根路径特殊检测 | +| 工具 Execute 无超时 | `Config.ToolTimeout=120s` 兜底(`context.WithTimeout`) | +| 确认回调无超时 | controller 的 `confirmFn` 内置 5 分钟超时 | +| 黑名单可被确认放行 | Check 策略第一层 Critical 直接 `return false, nil`,不走 confirmFn | +| LLM 编造工具名 | `registry.Get` 找不到时返回 observation「工具未找到」,LLM 自动换法 | +| 参数 JSON 解析失败 | `json.Unmarshal` 失败时返回 observation「参数解析失败」,不 panic | + +## 与 opsxcli 的差异总结 + +| 维度 | opsxcli | aiops | +|------|---------|-------| +| 确认机制 | `bufio.Reader` CLI 终端交互 | WebSocket `confirm` 事件 + 前端 `useConfirm()` 弹框 | +| 审计日志 | 文件 `~/.opsxcli/audit/audit.log` | DB 表 `agent_cli_messages` | +| 风险评估位置 | `safety/risk.go` | `tools/risk.go`(避免循环依赖) | +| 内存审计历史 | `history []ExecutionRecord`(上限 1000 FIFO) | 剥离(直接走 DB) | +| 工具集 | 6 个 | 2 个 | +| SSH 连接池 | 有(`ssh_pool.go`) | 剥离(用 `pkg/remoteexec`) | +| Closer 接口 | 有 | 剥离 | + +安全核心(三级模式 / 五级风险 / 黑名单 / 动态评估)完全保留,差异仅在确认机制和审计载体。 + +> 作者:Logan.Li diff --git a/docs/agent/10-会话管理.md b/docs/agent/10-会话管理.md new file mode 100644 index 0000000..e33c2a0 --- /dev/null +++ b/docs/agent/10-会话管理.md @@ -0,0 +1,388 @@ +# 10 - 会话管理 + +> 本文由 opsxcli 同名设计文档适配改写,结合 aiops Agent CLI 的 Web 实现(`backend/model/agent_cli.go` + `controller/agent_cli.go`)。 +> **核心改造**:opsxcli 用本地 JSONL 文件存储会话(单用户 CLI 场景);aiops 用 **GORM 数据库表**存储会话与消息,支持多用户并发、按 user_id 隔离、REST 查询与历史回放。 + +## 设计理念 + +- **会话管理**:Agent 引擎本身无状态(每次任务独立),所有对话过程持久化到会话层,支持历史回溯、审计、断点排查 +- **多用户隔离**:Web 场景下多个用户同时使用 Agent CLI,会话必须按 user_id 隔离 +- **结构化查询**:会话需要列表、筛选、删除等业务操作,必须走 DB 而非文件 + +> 为什么改用 DB?opsxcli 是 CLI 工具,单用户、单机使用,JSONL 文件够用且零依赖;aiops 是 Web 平台,多用户并发、需要列表查询和按主机/用户筛选、需要与其他业务表(Server、User)关联 —— DB 是必然选择。 + +## 架构总览 + +``` +┌────────────────────────────────────────────────────┐ +│ REST + WebSocket │ +│ /ws/agent-cli (WebSocket 推流) │ +│ /aiops/agent-cli/* (REST 会话管理) │ +├────────────────────────────────────────────────────┤ +│ controller/agent_cli.go │ +│ AgentCLIHandler │ +│ ├─ Run (WebSocket) │ +│ ├─ ListSessions / GetSession / DeleteSession │ +│ └─ agentConn (单连接运行态) │ +├────────────────────────────────────────────────────┤ +│ model/agent_cli.go (GORM) │ +│ AgentCLISession (agent_cli_sessions 表) │ +│ AgentCLIMessage (agent_cli_messages 表) │ +├────────────────────────────────────────────────────┤ +│ MySQL / SQLite (aiops 既有 DB) │ +└────────────────────────────────────────────────────┘ +``` + +## 核心数据模型 + +### AgentCLISession(会话) + +```go +// backend/model/agent_cli.go + +type AgentCLISession struct { + ID uint `gorm:"primarykey" json:"id"` + UserID uint `gorm:"index" json:"user_id"` // 发起用户(隔离) + ServerID uint `gorm:"index" json:"server_id"` // 目标主机 + ServerName string `gorm:"size:128" json:"server_name"` // 主机名快照 + ServerHost string `gorm:"size:64" json:"server_host"` // 主机 IP 快照 + Title string `gorm:"size:255" json:"title"` // 任务标题(query 截断) + Status string `gorm:"size:32;default:running" json:"status"` // running/done/aborted/error + Provider string `gorm:"size:50" json:"provider"` // LLM 提供商 + Model string `gorm:"size:100" json:"model"` // 模型名 + Rounds int `json:"rounds"` // ReAct 轮次 + TotalTokens int `json:"total_tokens"` // token 消耗 + Duration int64 `json:"duration"` // 耗时(毫秒) + Error string `gorm:"type:text" json:"error,omitempty"` // 错误信息 + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (AgentCLISession) TableName() string { return "agent_cli_sessions" } +``` + +**设计要点**: + +- **`UserID` 索引**:多用户隔离的核心,所有查询都按 user_id 过滤 +- **`ServerID` + `ServerName` + `ServerHost`**:关联主机的同时保存快照(主机 later 被删/改名时历史仍可读) +- **`Status` 四态**:`running`(进行中)/ `done`(完成)/ `aborted`(用户中断)/ `error`(出错或达最大轮次) +- **`Title`**:query 截断到 60 字符,作为会话列表的显示标题 + +### AgentCLIMessage(消息) + +```go +type AgentCLIMessage struct { + ID uint `gorm:"primarykey" json:"id"` + SessionID uint `gorm:"index;not null" json:"session_id"` + Round int `json:"round"` // ReAct 轮次 + Type string `gorm:"size:32" json:"type"` // thought/tool_call/tool_result/done/error/session + Role string `gorm:"size:32" json:"role"` // user/assistant/tool + Content string `gorm:"type:text" json:"content"` // 思考文本/最终答案 + ToolName string `gorm:"size:64" json:"tool_name"` // 工具名 + ToolArgs string `gorm:"type:text" json:"tool_args"` // 工具参数 JSON + ToolOutput string `gorm:"type:text" json:"tool_output"` // 工具输出 + Risk string `gorm:"size:32" json:"risk"` // 风险等级 + Success bool `json:"success"` + Duration string `gorm:"size:32" json:"duration"` // 工具耗时 + Tokens int `json:"tokens"` + CreatedAt time.Time `gorm:"index" json:"created_at"` +} + +func (AgentCLIMessage) TableName() string { return "agent_cli_messages" } +``` + +**消息类型映射引擎事件**: + +| Event.Type | DB Type | DB Role | Content | ToolName | ToolOutput | +|------------|---------|---------|---------|----------|------------| +| 用户输入 | (无 Type) | user | query | — | — | +| `thought` | thought | assistant | AI 思考文本 | — | — | +| `tool_call` | tool_call | tool | (空) | 工具名 | (空,Args 在 ToolArgs) | +| `tool_result` | tool_result | tool | 输出 | 工具名 | 输出(冗余便于查询) | +| `done` | done | assistant | 最终答案 | — | — | +| `error` | error | assistant | 错误信息 | — | — | +| `progress` | — | — | — | — | — |(不入库,太频繁) + +## 会话生命周期 + +``` +WebSocket start {query} + │ + ▼ +startTask + ├─ 创建 AgentCLISession (status=running) + ├─ 存用户消息 (role=user, content=query) + ├─ 构建 Engine、注入 Callback + └─ go engine.Run(ctx, query, serverInfo) + │ + │ 每个事件触发 onEvent → 落库 agent_cli_messages + │ ├─ thought → persistMsg(type=thought, role=assistant) + │ ├─ tool_call → persistMsg(type=tool_call, role=tool, args, risk) + │ ├─ tool_result → persistMsg(type=tool_result, role=tool, output, success) + │ ├─ done → persistMsg(type=done, role=assistant, answer, tokens) + │ └─ error → persistMsg(type=error, role=assistant, message) + │ + ▼ + finish (更新 session.status / rounds / duration / error) + ├─ ctx.Err() != nil → status=aborted + ├─ err != nil → status=error + └─ 否则 → status=done +``` + +## controller 实现 + +### startTask(创建会话) + +```go +// backend/controller/agent_cli.go + +func (ac *agentConn) startTask(sshCfg remoteexec.SSHConfig, llmCfg model.LLMConfig, query string) { + ac.abort() // 取消上一次任务 + + // 1. 创建 DB 会话 + title := query + if r := []rune(title); len(r) > 60 { + title = string(r[:60]) + "..." + } + sess := &model.AgentCLISession{ + UserID: ac.userID, ServerID: ac.server.ID, + ServerName: ac.server.Hostname, ServerHost: ac.server.IP, + Title: title, Status: "running", + Provider: llmCfg.Provider, Model: llmCfg.Model, + } + ac.db.Create(sess) + ac.session = sess + + // 2. 存用户消息 + ac.persistMsg(sess.ID, 0, "user", "user", query, "", "", "", "", false, "", 0) + + // 3. 构建 Engine + engine, _ := agentcli.NewEngine(...) + engine.SetCallback(ac.onEvent) + engine.SetConfirmFn(ac.confirmFn) + + // 4. 启动 ReAct 循环(goroutine) + go func() { + defer engine.Close() + _, err := engine.Run(ctx, query, serverInfo) + // ... finish + }() +} +``` + +### onEvent(事件落库 + 推 WS) + +```go +func (ac *agentConn) onEvent(e core.Event) { + ev := wsEvent{...} // 序列化 + if ac.session != nil { + ev.SessionID = ac.session.ID + } + ac.write(ev) // 推 WS + + // 持久化(progress 不入库) + if ac.session == nil { return } + sid := ac.session.ID + switch e.Type { + case core.EventThought: + ac.persistMsg(sid, e.Round, "thought", "assistant", e.Thought, ...) + case core.EventToolCall: + argsJSON, _ := json.Marshal(e.ToolArgs) + ac.persistMsg(sid, e.Round, "tool_call", "tool", "", e.ToolName, string(argsJSON), "", e.Risk, ...) + case core.EventToolResult: + ac.persistMsg(sid, e.Round, "tool_result", "tool", e.Output, e.ToolName, "", "", "", e.Success, e.Duration, 0) + case core.EventDone: + ac.persistMsg(sid, e.Round, "done", "assistant", e.Answer, ..., true, "", e.Tokens) + case core.EventError: + ac.persistMsg(sid, e.Round, "error", "assistant", e.Message, ...) + } +} +``` + +### finish(结束会话) + +```go +func (ac *agentConn) finish(sess *model.AgentCLISession, status, errMsg string, rounds int, dur time.Duration) { + // 推 WS 结束事件 + ac.write(wsEvent{Type: status, SessionID: sess.ID, Message: errMsg, Round: rounds}) + // 更新 DB + updates := map[string]interface{}{ + "status": status, "rounds": rounds, "duration": dur.Milliseconds(), + } + if errMsg != "" { + updates["error"] = errMsg + } + ac.db.Model(sess).Updates(updates) +} +``` + +### REST 接口 + +```go +// backend/router/routes.go + +agentCLIGroup := authorized.Group("/aiops/agent-cli") +{ + agentCLIGroup.GET("/sessions", agentCLIHandler.ListSessions) // 列表(按 user_id 过滤) + agentCLIGroup.GET("/sessions/:id", agentCLIHandler.GetSession) // 详情(含全部消息) + agentCLIGroup.DELETE("/sessions/:id", agentCLIHandler.DeleteSession) // 删除(校验归属) +} +``` + +前端 `frontend/src/api/agentCli.ts` 调用这些接口实现「会话历史」页面: + +- 列表展示用户的所有会话(标题、主机、状态、耗时、token) +- 点击某会话查看完整 ReAct 推理过程(thought / tool_call / tool_result 时间线) +- 删除会话(连带消息) + +## WebSocket 协议 + +### 后端 → 前端(wsEvent) + +```go +type wsEvent struct { + Type string `json:"type"` // session/thought/tool_call/tool_result/progress/done/error/confirm + Round int `json:"round,omitempty"` + SessionID uint `json:"session_id,omitempty"` + Thought string `json:"thought,omitempty"` + ToolName string `json:"tool_name,omitempty"` + ToolArgs map[string]interface{} `json:"tool_args,omitempty"` + Risk string `json:"risk,omitempty"` + Output string `json:"output,omitempty"` + Success bool `json:"success,omitempty"` + Duration string `json:"duration,omitempty"` + Tokens int `json:"tokens,omitempty"` + Answer string `json:"answer,omitempty"` + Message string `json:"message,omitempty"` + ConfirmID string `json:"confirm_id,omitempty"` // confirm 事件专属 + Command string `json:"command,omitempty"` // confirm 事件专属 +} +``` + +### 前端 → 后端(wsControl) + +```go +type wsControl struct { + Type string `json:"type"` // start/confirm/deny/abort + Query string `json:"query,omitempty"` // start 时任务描述 + ConfirmID string `json:"confirm_id,omitempty"` // confirm/deny 时 +} +``` + +## 并发与隔离 + +### 单连接串行 + +每个 WebSocket 连接(`agentConn`)同一时刻只运行一个任务: + +```go +func (ac *agentConn) startTask(...) { + ac.abort() // 取消上一次任务,再启动新的 +} +``` + +### 多用户隔离 + +- 每个会话绑定 `UserID`(JWT 中间件注入) +- REST 查询自动按 `user_id` 过滤 +- 删除会话时校验归属 + +### 线程安全 + +- `agentConn.writeMu sync.Mutex` —— 串行化 WebSocket 写入 +- `agentConn.cfMu sync.Mutex` —— 保护 `confirms` map +- 引擎 `core.Agent.mu sync.Mutex` —— 保护 token 计数等内部状态 + +## 持久化策略 + +### 落库失败不阻断 + +```go +func (ac *agentConn) persistMsg(...) { + if err := ac.db.Create(msg).Error; err != nil { + _ = err // 持久化失败不阻断主流程 + } +} +``` + +DB 写入失败时,WS 推送仍正常,用户体验不受影响(只是历史回放缺这条)。 + +### progress 不入库 + +`progress` 事件每个轮次都发,且只含 round 和 token,价值低、频率高,不入库(避免 DB 压力)。 + +## 历史回放 + +前端通过 `GET /sessions/:id` 拿到完整消息列表,按时间顺序渲染: + +``` +[会话 #42] 检查 nginx 启动失败的原因 + ├─ 用户:检查 nginx 启动失败的原因 + ├─ Round 1 [thought]:我先查看 nginx 服务状态... + ├─ Round 1 [tool_call] ssh_execute "systemctl status nginx" (高风险) + ├─ Round 1 [tool_result] failed (1.2s) [exit_code=3] + ├─ Round 2 [thought]:服务 failed,查看日志... + ├─ Round 2 [tool_call] ssh_execute "journalctl -u nginx -n 500" (安全) + ├─ Round 2 [tool_result] 500 行 (0.8s) + ├─ Round 3 [thought]:日志太长,先分析... + ├─ Round 3 [tool_call] analyze_output(output, focus="error") + ├─ Round 3 [tool_result] 8 行疑似错误 (0.05s) + ├─ Round 4 [thought]:端口被占,确认一下... + ├─ Round 4 [tool_call] ssh_execute "ss -tlnp | grep :80" (安全) + ├─ Round 4 [tool_result] (0.3s) + └─ [done] 根因:80 端口被 apache 占用。建议:systemctl stop httpd && systemctl start nginx +``` + +这是 opsxcli「会话导出为 Markdown」的 Web 升级版 —— 不需要导出,直接在线回放。 + +## 扩展指南 + +### 添加会话标签 / 收藏 + +1. 在 `AgentCLISession` 增加 `Tags string` / `Starred bool` 字段 +2. 增加 REST 接口 `PATCH /sessions/:id` +3. 前端列表增加标签筛选 + +### 会话统计 + +```go +// 例:用户本月使用的 token 总数 +db.Model(&AgentCLIMessage{}). + Where("session_id IN (?) AND type = ?", + db.Model(&AgentCLISession{}).Select("id").Where("user_id = ?", uid), + "done"). + Select("COALESCE(SUM(tokens), 0)").Scan(&totalTokens) +``` + +### 导出会话为 Markdown + +参考 opsxcli 的 `ExportMarkdown`,在 controller 增加一个 REST 接口,遍历消息组装 Markdown 文本返回。 + +## 常见陷阱 + +| 陷阱 | 解决方案 | +|------|----------| +| 多用户看到别人的会话 | 所有查询按 `user_id` 过滤,删除时校验归属 | +| WebSocket 断开导致会话悬挂 | `ctx.Err()` 触发 finish(status=aborted);前端重连后可查看历史 | +| 消息表无限增长 | 定期清理老会话(运维任务);或按 `created_at` 分区 | +| 落库失败丢失历史 | 不阻断主流程,但应在日志记录;progress 不入库减少压力 | +| 长任务超过 WS 超时 | ReAct 循环每步检查 `ctx.Err()`,前端断开 → ctx cancel → finish(aborted) | +| 并发写 WS 错乱 | `writeMu sync.Mutex` 串行化写入 | +| ConfirmFn 无人响应 | 5 分钟超时自动拒绝 | +| `lastRound` 不准 | 从 DB 查 `MAX(round)` 而非内存计数(断线重连场景) | + +## 与 opsxcli 的差异总结 + +| 维度 | opsxcli | aiops | +|------|---------|-------| +| 存储后端 | JSONL 文件(`~/.opsxcli/agent/sessions/`) | GORM DB(`agent_cli_sessions` / `agent_cli_messages` 表) | +| 多用户 | 不支持(单用户 CLI) | 原生支持(user_id 索引隔离) | +| 会话结构 | `{session-id}/meta.json + messages.jsonl` | 单表 sessions + 单表 messages(一对多) | +| 查询能力 | 只能遍历文件 | SQL 任意查询、JOIN、聚合 | +| 导出 | ExportMarkdown / ExportJSON / ExportToFile | 在线回放(前端时间线) | +| 消息字段 | role/content/tool_calls | type/role/content/tool_name/tool_args/tool_output/risk/success/duration/tokens | +| 接口 | Manager 接口(10 方法) | REST(3 方法)+ WebSocket 推流 | + +存储载体变了,但「会话 = 元数据 + 消息流」的核心模型保留。DB 方案在多用户 Web 场景下是必然选择,也使会话与其他业务表(Server、User、LLMConfig)的关联变得自然。 + +> 作者:Logan.Li diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7f27365..658ffa2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -54,6 +54,8 @@ const CronJobManager = lazy(() => import('@/components/automation/CronJobManager const AIOpsAssistant = lazy(() => import('@/components/aiops/AIOpsAssistant').then(m => ({ default: m.AIOpsAssistant }))); const AIAlertAnalyzer = lazy(() => import('@/components/aiops/AIAlertAnalyzer').then(m => ({ default: m.AIAlertAnalyzer }))); const AIPredictiveAnalytics = lazy(() => import('@/components/aiops/AIPredictiveAnalytics').then(m => ({ default: m.AIPredictiveAnalytics }))); +const AgentCLI = lazy(() => import('@/components/aiops/AgentCLI').then(m => ({ default: m.default }))); +const LLMConfigPage = lazy(() => import('@/components/aiops/LLMConfig').then(m => ({ default: m.default }))); const AlertCenter = lazy(() => import('@/components/monitor/alert-center/AlertCenter').then(m => ({ default: m.default }))); const TraceManager = lazy(() => import('@/components/monitor/trace/TraceManager').then(m => ({ default: m.default }))); const HostMonitor = lazy(() => import('@/pages/HostMonitor').then(m => ({ default: m.HostMonitor }))); @@ -197,6 +199,8 @@ function App() { } /> } /> } /> + } /> + } /> {/* Audit — 各 Tab 独立路由,支持 URL 直链 // Logan.Li */} } /> diff --git a/frontend/src/api/agentCli.ts b/frontend/src/api/agentCli.ts new file mode 100644 index 0000000..bbcbcbd --- /dev/null +++ b/frontend/src/api/agentCli.ts @@ -0,0 +1,62 @@ +// Agent CLI API — WebSocket 连接 + 会话历史 REST // Logan.Li +import { http } from '@/utils/request'; +import { useUserStore } from '@/store'; + +// 会话与消息类型(与后端 model.AgentCLISession/Message 对应) // Logan.Li +export interface AgentCliSession { + id: number; + user_id: number; + server_id: number; + server_name: string; + server_host: string; + title: string; + status: string; // running/done/aborted/error + provider: string; + model: string; + rounds: number; + total_tokens: number; + duration: number; + error?: string; + created_at: string; + updated_at: string; +} + +export interface AgentCliMessage { + id: number; + session_id: number; + round: number; + type: string; // thought/tool_call/tool_result/done/error/user + role: string; + content: string; + tool_name: string; + tool_args: string; + tool_output: string; + risk: string; + success: boolean; + duration: string; + tokens: number; + created_at: string; +} + +export const agentCliApi = { + // 会话列表(按当前用户隔离) + getSessions: async (params?: { server_id?: number; page?: number; page_size?: number }) => { + return http.get<{ list: AgentCliSession[]; total: number }>('/aiops/agent-cli/sessions', params); + }, + // 会话详情(含完整推理消息,供回放) + getSession: async (id: number | string) => { + return http.get<{ session: AgentCliSession; messages: AgentCliMessage[] }>(`/aiops/agent-cli/sessions/${id}`); + }, + // 删除会话 + deleteSession: async (id: number | string) => { + return http.delete(`/aiops/agent-cli/sessions/${id}`); + }, +}; + +// buildAgentCliWsUrl 构造 Agent CLI WebSocket 地址(浏览器无法设 header,用 ?token= 回退) // Logan.Li +export function buildAgentCliWsUrl(serverId: number | string): string { + const proto = typeof location !== 'undefined' && location.protocol === 'https:' ? 'wss' : 'ws'; + // Token 双保险:store 优先,localStorage 兜底 // Logan.Li + const token = useUserStore.getState().token || localStorage.getItem('token') || ''; + return `${proto}://${location.host}/ws/agent-cli?id=${serverId}&token=${encodeURIComponent(token)}`; +} diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 05d3f5a..e583e24 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -12,3 +12,4 @@ export * from './agent'; export * from './cloud'; export * from './system'; export * from './ai'; +export * from './agentCli'; diff --git a/frontend/src/components/aiops/AgentCLI.tsx b/frontend/src/components/aiops/AgentCLI.tsx new file mode 100644 index 0000000..97fef3f --- /dev/null +++ b/frontend/src/components/aiops/AgentCLI.tsx @@ -0,0 +1,379 @@ +// AgentCLI.tsx - AI 运维 Agent 执行页面(Claude Code 风格 Web 版) // Logan.Li +// 关联主机后,用自然语言描述任务,AI 通过 SSH 工具自主排障/部署,循环重试直到解决。 +// 通过 WebSocket 实时展示思考、执行的命令、输出与结果;高危命令弹框确认。 +import { useState, useRef, useEffect, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from '@/components/ui/select'; +import { useConfirm } from '@/components/ui/confirm-dialog'; +import { serverApi, buildAgentCliWsUrl } from '@/api'; +import { + Send, Square, Terminal, Settings, ChevronDown, ChevronRight, + Shield, Brain, Cpu, Database, Loader2, Bot, AlertTriangle, CheckCircle2, Lightbulb, Server, +} from 'lucide-react'; + +// 对话流条目模型 // Logan.Li +interface ChatItem { + id: number; + kind: 'user' | 'thought' | 'tool' | 'done' | 'error' | 'info'; + round?: number; + content?: string; + toolName?: string; + command?: string; + risk?: string; + output?: string; + success?: boolean; + duration?: string; + pending?: boolean; +} + +// 四大核心模块说明(页面 tips) // Logan.Li +const TIPS = [ + { icon: Cpu, title: 'LLM + 工具层(llm / tools)', color: 'text-sky-400', + desc: 'OpenAI 兼容 LLM 客户端(复用「大模型设置」中的配置);工具系统注册 ssh_execute(远程执行命令,复用 pkg/remoteexec)与 analyze_output(超长输出分析)。AI 借助工具真正动手操作服务器。' }, + { icon: Shield, title: '安全 + Prompt(safety / prompt)', color: 'text-amber-400', + desc: '五级风险评估 + 三级安全模式:危险命令(rm -rf /、mkfs)硬性拒绝,高危命令(reboot、restart)弹框确认;五层 System Prompt 定义角色、安全规则、工具与 ReAct 工作方式。' }, + { icon: Brain, title: 'ReAct 核心引擎(core)', color: 'text-violet-400', + desc: '「思考→行动→观察」循环(最多 16 轮):每轮调用工具、读取输出,失败则分析原因换法重试;自适应检查点 + 循环检测(同参超过 3 次拒绝),直到真正解决问题而非假装解决。' }, + { icon: Database, title: '自进化引擎(evolver)', color: 'text-emerald-400', + desc: '四层记忆(经验 / 环境 / 事实 / 程序):任务完成后异步学习经验,下次同类任务自动注入历史经验,越用越聪明;学习失败优雅降级,绝不影响主流程。' }, +]; + +// riskVariant 风险等级对应 badge 颜色 // Logan.Li +function riskClass(risk?: string): string { + switch ((risk || '').toLowerCase()) { + case '安全': case 'safe': return 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30'; + case '低风险': case 'low': return 'bg-sky-500/15 text-sky-400 border-sky-500/30'; + case '中风险': case 'medium': return 'bg-amber-500/15 text-amber-400 border-amber-500/30'; + case '高风险': case 'high': return 'bg-orange-500/15 text-orange-400 border-orange-500/30'; + case '危险': case 'critical': return 'bg-red-500/15 text-red-400 border-red-500/30'; + default: return 'bg-slate-500/15 text-slate-400 border-slate-500/30'; + } +} + +export default function AgentCLI() { + const navigate = useNavigate(); + const confirm = useConfirm(); + + const [servers, setServers] = useState<{ id: number; hostname: string; ip: string }[]>([]); + const [serverId, setServerId] = useState(''); + const [items, setItems] = useState([]); + const [input, setInput] = useState(''); + const [running, setRunning] = useState(false); + const [round, setRound] = useState(0); + const [tokens, setTokens] = useState(0); + const [tipsOpen, setTipsOpen] = useState(true); + const [connected, setConnected] = useState(false); + + const wsRef = useRef(null); + const idSeq = useRef(0); + const scrollRef = useRef(null); + + const nextId = () => ++idSeq.current; + const push = useCallback((it: Omit) => { + setItems((prev) => [...prev, { ...it, id: nextId() }]); + }, []); + + // 加载主机列表 // Logan.Li + useEffect(() => { + serverApi.getServers({ page: 1, page_size: 500 }).then((res) => { + setServers((res as { list?: { id: number; hostname: string; ip: string }[] })?.list || []); + }).catch(() => {}); + }, []); + + // 选中主机后建立 WebSocket 连接 // Logan.Li + useEffect(() => { + if (!serverId) return; + setItems([]); + setRound(0); setTokens(0); setRunning(false); + const ws = new WebSocket(buildAgentCliWsUrl(serverId)); + wsRef.current = ws; + ws.onopen = () => setConnected(true); + ws.onclose = () => { setConnected(false); setRunning(false); }; + ws.onmessage = (e) => { + try { handleEvent(JSON.parse(e.data)); } catch { /* 忽略非 JSON */ } + }; + return () => { ws.close(); wsRef.current = null; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [serverId]); + + // 自动滚到底部 // Logan.Li + useEffect(() => { + scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' }); + }, [items]); + + // 处理后端事件 // Logan.Li + const handleEvent = (ev: Record) => { + const type = ev.type as string; + switch (type) { + case 'session': + push({ kind: 'info', content: ev.message as string }); + break; + case 'progress': + if (ev.round) setRound(ev.round as number); + if (ev.tokens) setTokens(ev.tokens as number); + break; + case 'thought': + push({ kind: 'thought', round: ev.round as number, content: ev.thought as string }); + break; + case 'tool_call': { + const args = (ev.tool_args || {}) as Record; + push({ + kind: 'tool', round: ev.round as number, toolName: ev.tool_name as string, + command: (args.command as string) || '', risk: ev.risk as string, pending: true, + }); + break; + } + case 'tool_result': + // 配对:从末尾找最近的 pending 同名工具块填充输出 // Logan.Li + setItems((prev) => { + const next = [...prev]; + for (let i = next.length - 1; i >= 0; i--) { + if (next[i].kind === 'tool' && next[i].pending && next[i].toolName === ev.tool_name) { + next[i] = { + ...next[i], output: ev.output as string, success: ev.success as boolean, + duration: ev.duration as string, pending: false, + }; + break; + } + } + return next; + }); + break; + case 'confirm': + handleConfirm(ev); + break; + case 'done': + push({ kind: 'done', content: ev.answer as string, round: ev.round as number }); + setRunning(false); + break; + case 'error': + case 'aborted': + push({ kind: type === 'aborted' ? 'info' : 'error', content: (ev.message as string) || (type === 'aborted' ? '任务已中断' : '发生错误') }); + setRunning(false); + break; + } + }; + + // 高危命令确认(后端驱动 → 弹框 → 回传) // Logan.Li + const handleConfirm = async (ev: Record) => { + const ok = await confirm({ + title: '⚠️ 确认执行高危操作', + description: `工具:${ev.tool_name}\n风险:${ev.risk}\n命令:${ev.command || '(无)'}`, + confirmText: '确认执行', + cancelText: '拒绝', + variant: 'destructive', + }); + wsRef.current?.send(JSON.stringify({ type: ok ? 'confirm' : 'deny', confirm_id: ev.confirm_id })); + if (!ok) push({ kind: 'info', content: `已拒绝执行:${ev.command || ev.tool_name}` }); + }; + + // 发送任务 // Logan.Li + const send = () => { + const q = input.trim(); + if (!q || running || !wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return; + push({ kind: 'user', content: q }); + wsRef.current.send(JSON.stringify({ type: 'start', query: q })); + setInput(''); + setRunning(true); + }; + + // 中断任务 // Logan.Li + const stop = () => wsRef.current?.send(JSON.stringify({ type: 'abort' })); + + return ( +
+ {/* 顶部工具栏:主机选择 + 状态 + 大模型设置入口 */} + + +
+ Agent CLI +
+
+ + + {connected && ( + + 已连接 + + )} + {running && ( + + 第 {round} 轮 · {tokens} tokens + + )} +
+ + + + + {/* tips 面板:核心功能 / 逻辑 / 设计思路 */} + + setTipsOpen((v) => !v)}> +
+ {tipsOpen ? : } + + 核心能力与设计思路 + — Agent CLI 由四大模块构成,参考 opsxcli AI Agent 移植 +
+
+ {tipsOpen && ( + + {TIPS.map((t) => ( +
+ +
+
{t.title}
+
{t.desc}
+
+
+ ))} +
+ )} +
+ + {/* 对话流 */} + +
+ {items.length === 0 && ( +
+ +
{serverId ? '描述你要执行的任务,例如「检查磁盘使用率并清理大文件」' : '请先选择一台目标主机'}
+
+ )} + {items.map((it) => ( + + ))} +
+ {/* 输入区 */} +
+