feat: 新增 Agent CLI — Web 版 AI 运维 Agent(参考 opsxcli 移植)

后端引擎 backend/pkg/agentcli/(纯引擎库,零业务依赖,剥离 CLI/TUI 外壳):
- llm: OpenAI 兼容客户端,复用 model.LLMConfig
- tools: ssh_execute(复用 pkg/remoteexec)+ analyze_output + 风险评估黑名单
- safety: 三级安全模式 + 五级风险 + WebSocket 异步确认
- prompt: 五层 System Prompt
- core: ReAct 16 轮循环 + 自适应检查点 + 循环检测
- evolver: 四层记忆 + 进化循环,data/agentcli/ 文件存储

集成层:
- controller/agent_cli.go: WebSocket 控制器(事件流推流 + 高危确认 + 会话持久化)
- model/agent_cli.go: agent_cli_sessions/messages 表 + AutoMigrate
- 路由 /ws/agent-cli + /api/v1/aiops/agent-cli/sessions
- seed 权限 aiops:agent-cli / aiops:llm-config

前端:
- AgentCLI.tsx: 对话流 + 工具块 + 高危确认 + 四模块 tips 面板
- LLMConfig.tsx: 大模型设置独立页
- api/agentCli.ts + Sidebar/App 菜单路由

文档:
- docs/agent/ 11 篇(opsxcli 同名文档适配改写 + 00-README 映射总览)

其他:
- CLAUDE.md 补充 agent-cli 模块说明
- 修正 .gitignore 根级二进制规则(前导 /,避免误伤 frontend/src/components/aiops/)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
attackerandClaude committed 2026-06-27 09:55:17 +08:00
1 parent f2069fe3f0
commit 92a2247390
37 files changed
+6449 -6

No files matched your search

+109
View File
@@ -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()
}
}
+436
View File
@@ -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 工具无连接池),保留接口位
}
}
+271
View File
@@ -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
}
+246
View File
@@ -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),
}
}
+145
View File
@@ -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)
}
+73
View File
@@ -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
}
+73
View File
@@ -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()
}
+89
View File
@@ -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()
}
+106
View File
@@ -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
}
+169
View File
@@ -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
}
+170
View File
@@ -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
}
+100
View File
@@ -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
}
+2
View File
@@ -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