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:
1 parent
f2069fe3f0
commit
92a2247390
37 files changed
+6449
-6
No files matched your search
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 工具无连接池),保留接口位
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user