feat: Agent CLI 与终端审计回放全面优化

- Agent CLI: 多轮对话、SSH 长连接、流式推理、Evolver 按用户隔离、历史侧栏
- 终端审计: 回放播放器、命令书签、筛选分页导出、命令输出统计、录制落盘修复
- 修复 session_commands 表迁移、Kill 竞态导致录制丢失、无录制会话回放提示
- 审计历史页只读(移除删除),移除回放弹框录制搜索
This commit is contained in:
Logan committed 2026-07-01 12:13:53 +08:00
1 parent 9a3dd304f7
commit 1acb0c49af
81 files changed
+3916 -1655

No files matched your search

+14 -13
View File
@@ -11,6 +11,7 @@ package agentcli
import (
"context"
"fmt"
"ezops/pkg/agentcli/core"
"ezops/pkg/agentcli/evolver"
@@ -54,9 +55,13 @@ func NewEngine(cfg EngineConfig) (*Engine, error) {
// LLM 客户端
client := llm.NewClient(cfg.LLM)
// 工具注册中心:ssh_execute(注入目标主机凭据)+ analyze_output
// 工具注册中心:ssh_execute(SSH 长连接)+ analyze_output
registry := tools.NewRegistry()
registry.Register(tools.NewSSHExecuteTool(cfg.SSH, coreCfg.ToolTimeout))
sshTool, err := tools.NewSSHExecuteTool(cfg.SSH, coreCfg.ToolTimeout)
if err != nil {
return nil, fmt.Errorf("ssh_execute 初始化失败: %w", err)
}
registry.Register(sshTool)
registry.Register(tools.NewAnalyzeOutputTool())
// 安全控制器
@@ -81,16 +86,9 @@ 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
// Run 执行任务(ReAct 循环);prior 为上一轮对话历史(不含 system),priorTokens 为累计 token // Logan.Li
func (e *Engine) Run(ctx context.Context, query, serverInfo string, prior []llm.Message, priorTokens int) (*core.RunResult, error) {
return e.agent.Run(ctx, query, serverInfo, prior, priorTokens)
}
// EvolveStats 进化统计(供审计展示) // Logan.Li
@@ -101,8 +99,11 @@ func (e *Engine) EvolveStats() map[string]interface{} {
return e.evolver.GetStats()
}
// Close 释放资源 // Logan.Li
// Close 释放资源(含 SSH 长连接) // Logan.Li
func (e *Engine) Close() {
if e.tools != nil {
e.tools.Close()
}
if e.agent != nil {
e.agent.Close()
}
+83 -22
View File
@@ -23,8 +23,9 @@ import (
type EventType string
const (
EventThought EventType = "thought" // AI 思考/推理文本
EventToolCall EventType = "tool_call" // 即将执行的工具(名/参数/风险)
EventThought EventType = "thought" // AI 思考/推理文本(流式结束后的完整内容)
EventThoughtDelta EventType = "thought_delta" // AI 思考流式增量 token
EventToolCall EventType = "tool_call" // 即将执行的工具(名/参数/风险)
EventToolResult EventType = "tool_result" // 工具执行结果(输出/成败/耗时)
EventProgress EventType = "progress" // 轮次进度 + token
EventDone EventType = "done" // 任务完成(最终答案)
@@ -50,6 +51,14 @@ type Event struct {
// EventCallback 事件回调(controller 注入) // Logan.Li
type EventCallback func(Event)
// RunResult ReAct 执行结果(含对话历史供多轮追问) // Logan.Li
type RunResult struct {
Success bool
Output string
History []llm.Message // system 之后的对话历史
TotalTokens int
}
// Config Agent 运行配置 // Logan.Li
type Config struct {
MaxIterations int
@@ -60,6 +69,7 @@ type Config struct {
OutputMaxLength int
MaxContextTokens int
MemoryDir string // Evolver 记忆目录
RoundOffset int // 轮次偏移(多轮追问时接续上一轮编号) // Logan.Li
}
// DefaultConfig 默认配置 // Logan.Li
@@ -112,9 +122,9 @@ func (a *Agent) emit(e Event) {
}
}
// Run 执行单次任务(ReAct 循环 + Evolver 集成) // Logan.Li
func (a *Agent) Run(ctx context.Context, query, serverInfo string) (*tools.Result, error) {
a.totalTokens = 0
// Run 执行单次任务(ReAct 循环 + Evolver 集成);prior 为不含 system 的历史对话 // Logan.Li
func (a *Agent) Run(ctx context.Context, query, serverInfo string, prior []llm.Message, priorTokens int) (*RunResult, error) {
a.totalTokens = priorTokens
start := time.Now()
// Evolver PREDICT:获取历史经验注入 system prompt
@@ -127,10 +137,11 @@ func (a *Agent) Run(ctx context.Context, query, serverInfo string) (*tools.Resul
toolDescs := a.buildToolDescriptions()
systemPrompt := prompt.BuildSystemPrompt(serverInfo, toolDescs, evolveCtx)
messages := []llm.Message{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: query},
messages := []llm.Message{{Role: "system", Content: systemPrompt}}
if len(prior) > 0 {
messages = append(messages, prior...)
}
messages = append(messages, llm.Message{Role: "user", Content: query})
llmTools := a.registry.ToLLMTools()
toolCallHistory := make(map[string]int) // 循环检测
@@ -144,7 +155,7 @@ func (a *Agent) Run(ctx context.Context, query, serverInfo string) (*tools.Resul
return nil, err
}
a.emit(Event{Type: EventProgress, Round: iter + 1})
a.emit(Event{Type: EventProgress, Round: a.roundNum(iter)})
// 自适应检查点
if hint := a.checkAdaptiveCheckpoint(iter, toolCallHistory, consecutiveFailures, totalToolCalls); hint != "" {
@@ -154,12 +165,17 @@ func (a *Agent) Run(ctx context.Context, query, serverInfo string) (*tools.Resul
// 裁剪历史
trimmed := a.trimMessages(messages)
// 调用 LLM
resp, err := a.llm.Complete(ctx, &llm.CompletionRequest{
// 调用 LLM(流式推送思考增量,降低长推理等待感) // Logan.Li
round := a.roundNum(iter)
resp, err := a.llm.Stream(ctx, &llm.CompletionRequest{
Messages: trimmed,
Tools: llmTools,
Temperature: a.config.Temperature,
MaxTokens: a.config.MaxTokens,
}, func(delta string) {
if strings.TrimSpace(delta) != "" {
a.emit(Event{Type: EventThoughtDelta, Round: round, Thought: delta})
}
})
if err != nil {
a.emit(Event{Type: EventError, Message: "LLM 调用失败: " + err.Error()})
@@ -169,9 +185,9 @@ func (a *Agent) Run(ctx context.Context, query, serverInfo string) (*tools.Resul
a.totalTokens += resp.Usage.TotalTokens
a.emit(Event{Type: EventProgress, Tokens: a.totalTokens})
// 推送思考文本
// 推送完整思考文本(供持久化与回放) // Logan.Li
if strings.TrimSpace(resp.Message.Content) != "" {
a.emit(Event{Type: EventThought, Round: iter + 1, Thought: resp.Message.Content})
a.emit(Event{Type: EventThought, Round: round, Thought: resp.Message.Content})
}
messages = append(messages, resp.Message)
@@ -180,11 +196,14 @@ func (a *Agent) Run(ctx context.Context, query, serverInfo string) (*tools.Resul
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
return &RunResult{
Success: true, Output: answer,
History: a.extractHistory(messages), TotalTokens: a.totalTokens,
}, nil
}
// 处理工具调用
observations, records, failInc := a.processToolCalls(ctx, resp.Message.ToolCalls, toolCallHistory, iter+1)
observations, records, failInc := a.processToolCalls(ctx, resp.Message.ToolCalls, toolCallHistory, round)
toolRecords = append(toolRecords, records...)
messages = append(messages, observations...)
totalToolCalls += len(resp.Message.ToolCalls)
@@ -195,7 +214,9 @@ func (a *Agent) Run(ctx context.Context, query, serverInfo string) (*tools.Resul
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)
return &RunResult{
Success: false, History: a.extractHistory(messages), TotalTokens: a.totalTokens,
}, fmt.Errorf("%s", msg)
}
// processToolCalls 处理工具调用,返回 observation 消息 + 记录 // Logan.Li
@@ -264,6 +285,13 @@ func (a *Agent) processToolCalls(ctx context.Context, calls []llm.ToolCall, hist
success = false
}
// 超长 ssh 输出自动分析,避免 token 爆炸 // Logan.Li
if tc.Function.Name == "ssh_execute" && success && len(output) > a.autoAnalyzeThreshold() {
if analyzed := a.autoAnalyzeOutput(ctx, output); analyzed != "" {
output = analyzed
}
}
// 输出截断
if len(output) > a.config.OutputMaxLength {
output = output[:a.config.OutputMaxLength] +
@@ -313,6 +341,43 @@ func (a *Agent) triggerEvolve(ctx context.Context, query, serverHost, answer str
}()
}
// roundNum 计算对外展示的 ReAct 轮次(含多轮追问偏移) // Logan.Li
func (a *Agent) roundNum(iter int) int {
return a.config.RoundOffset + iter + 1
}
// extractHistory 提取 system 之后的对话历史(供多轮追问) // Logan.Li
func (a *Agent) extractHistory(messages []llm.Message) []llm.Message {
if len(messages) <= 1 {
return nil
}
out := make([]llm.Message, len(messages)-1)
copy(out, messages[1:])
return out
}
// autoAnalyzeThreshold 触发自动 analyze_output 的输出长度阈值 // Logan.Li
func (a *Agent) autoAnalyzeThreshold() int {
t := a.config.OutputMaxLength / 2
if t < 4000 {
return 4000
}
return t
}
// autoAnalyzeOutput 对超长命令输出自动调用 analyze_output // Logan.Li
func (a *Agent) autoAnalyzeOutput(ctx context.Context, output string) string {
tool, err := a.registry.Get("analyze_output")
if err != nil {
return ""
}
r, err := tool.Execute(ctx, map[string]interface{}{"output": output})
if err != nil || r == nil || !r.Success || strings.TrimSpace(r.Output) == "" {
return ""
}
return r.Output + "\n[系统自动分析超长输出]"
}
// buildToolDescriptions 工具描述文本 // Logan.Li
func (a *Agent) buildToolDescriptions() string {
var sb strings.Builder
@@ -428,9 +493,5 @@ 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 工具无连接池),保留接口位
}
}
// Close 释放资源(SSH 长连接由 Engine.Close 统一释放) // Logan.Li
func (a *Agent) Close() {}
+36
View File
@@ -42,6 +42,7 @@ type EvolveResult struct {
LearnedHint string `json:"learned_hint"`
ToolSequence []string `json:"tool_sequence"`
ExperienceAdded bool `json:"experience_added"`
SkillAdded bool `json:"skill_added"`
Consolidated bool `json:"consolidated"`
}
@@ -107,6 +108,21 @@ func (e *EvolverEngine) Evolve(ctx context.Context, exec *TaskExecution) *Evolve
go e.memorizeFact(ctx, exec) // 异步,不阻塞进化主流程
}
// Step 6: PROCEDURAL —— 成功任务沉淀程序记忆(工具命令序列) // Logan.Li
if exec.Success {
if steps := extractSSHSteps(exec.ToolCalls); len(steps) >= 2 {
skill := SkillEntry{
Name: result.TaskType + "标准流程",
When: result.TaskType,
Steps: steps,
Timestamp: time.Now().Unix(),
}
if err := e.mem.AddSkill(skill); err == nil {
result.SkillAdded = true
}
}
}
return result
}
@@ -194,6 +210,12 @@ func (e *EvolverEngine) GetContextForPrompt(query string) string {
if hosts := e.mem.Hosts(); len(hosts) > 0 {
sb.WriteString(fmt.Sprintf(" 【已知主机】%s\n", strings.Join(hosts, ", ")))
}
if skills := e.mem.FindSkills(taskType, 2); len(skills) > 0 {
sb.WriteString(" 【推荐流程】\n")
for _, sk := range skills {
sb.WriteString(fmt.Sprintf(" - %s: %s\n", sk.Name, strings.Join(sk.Steps, " → ")))
}
}
return sb.String()
}
@@ -258,6 +280,20 @@ func resultToolNames(calls []ToolCallRecord) []string {
return out
}
// extractSSHSteps 从工具调用记录提取 ssh_execute 命令序列 // Logan.Li
func extractSSHSteps(calls []ToolCallRecord) []string {
out := make([]string, 0, len(calls))
for _, c := range calls {
if c.ToolName != "ssh_execute" {
continue
}
if cmd, ok := c.Args["command"].(string); ok && strings.TrimSpace(cmd) != "" {
out = append(out, strings.TrimSpace(cmd))
}
}
return out
}
func uniqueStrings(in []string) []string {
seen := make(map[string]bool)
out := make([]string, 0, len(in))
+15
View File
@@ -235,6 +235,21 @@ func (m *Memories) Hosts() []string {
return readJSON(m.envPath, KnownHosts{}).Hosts
}
// FindSkills 按任务类型查找程序记忆(运维流程,最多 n 条) // Logan.Li
func (m *Memories) FindSkills(taskType string, n int) []SkillEntry {
all := readJSONL[SkillEntry](m.sklPath)
var matched []SkillEntry
for _, s := range all {
if taskType == "" || s.When == taskType {
matched = append(matched, s)
}
}
if len(matched) > n {
matched = matched[len(matched)-n:]
}
return matched
}
// CountStats 统计各层记忆条目数 // Logan.Li
func (m *Memories) CountStats() map[string]int {
return map[string]int{
+26
View File
@@ -42,6 +42,12 @@ type anthropicMessage struct {
Content json.RawMessage `json:"content"`
}
// anthropicThinking 启用 Claude thinking 模式(Claude 3.7 Sonnet 支持) // Logan.Li
type anthropicThinking struct {
Type string `json:"type"`
BudgetTokens int `json:"budget_tokens"`
}
// anthropicRequest Anthropic /v1/messages 请求体 // Logan.Li
type anthropicRequest struct {
Model string `json:"model"`
@@ -51,6 +57,7 @@ type anthropicRequest struct {
Tools []anthropicTool `json:"tools,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
Stream bool `json:"stream,omitempty"`
Thinking *anthropicThinking `json:"thinking,omitempty"` // Claude 3.7 Sonnet 推理模式 // Logan.Li
}
// anthropicTool Anthropic 工具定义(input_schema,对应 OpenAI 的 parameters) // Logan.Li
@@ -157,6 +164,12 @@ func buildTools(tools []Tool) []anthropicTool {
return out
}
// isAnthropicThinkingModel 判断是否为支持 thinking 的 Claude 模型 // Logan.Li
func isAnthropicThinkingModel(model string) bool {
m := strings.ToLower(model)
return strings.Contains(m, "claude-3-7") || strings.Contains(m, "claude-3.7")
}
// buildRequest 构造 Anthropic 请求体(Complete 与 Stream 共用) // Logan.Li
func (c *AnthropicClient) buildRequest(req *CompletionRequest, stream bool) ([]byte, error) {
system, msgs := c.buildMessages(req)
@@ -168,6 +181,19 @@ func (c *AnthropicClient) buildRequest(req *CompletionRequest, stream bool) ([]b
if payload.MaxTokens <= 0 {
payload.MaxTokens = 4096
}
// 启用 Claude thinking 模式时,temperature 必须固定为 1,max_tokens 需大于 budget_tokens // Logan.Li
enabled := req.ReasoningEnabled || c.cfg.ReasoningEnabled
if enabled && isAnthropicThinkingModel(payload.Model) {
payload.Temperature = 1
budget := payload.MaxTokens / 2
if budget < 1024 {
budget = 1024
}
if payload.MaxTokens <= budget {
payload.MaxTokens = budget + 1024
}
payload.Thinking = &anthropicThinking{Type: "enabled", BudgetTokens: budget}
}
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("序列化请求失败: %w", err)
+47 -14
View File
@@ -17,13 +17,14 @@ import (
// Config LLM 客户端配置(endpoint 已由上层解析为完整 base url) // Logan.Li
type Config struct {
Protocol string // "openai" | "anthropic",由 client.go 工厂决定具体实现
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)
Protocol string // "openai" | "anthropic",由 client.go 工厂决定具体实现
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
ReasoningEnabled bool // 是否启用推理/Plan 模式(OpenAI o-series / DeepSeek R1 等) // Logan.Li
Timeout time.Duration // HTTP 超时(0 表示使用默认 180s)
}
// OpenAIClient OpenAI 兼容客户端 // Logan.Li
@@ -48,15 +49,45 @@ func NewOpenAIClient(cfg Config) Client {
// Name 返回客户端名称 // Logan.Li
func (c *OpenAIClient) Name() string { return "openai-compatible(" + c.cfg.Model + ")" }
// isOpenAIReasoningModel 判断是否为需要 reasoning_effort 的 OpenAI o-series 模型 // Logan.Li
func isOpenAIReasoningModel(model string) bool {
m := strings.ToLower(model)
return strings.Contains(m, "o1") || strings.Contains(m, "o3")
}
// isGenericReasoningModel 判断是否为通用推理开关模型(DeepSeek R1 等) // Logan.Li
func isGenericReasoningModel(model string) bool {
m := strings.ToLower(model)
return strings.Contains(m, "reasoning") || strings.Contains(m, "r1") || strings.Contains(m, "deepseek-reasoner")
}
// applyReasoningParams 根据模型类型应用 reasoning 参数 // Logan.Li
func applyReasoningParams(payload *openaiRequest, enabled bool) {
if !enabled {
return
}
if isOpenAIReasoningModel(payload.Model) {
// OpenAI o-series 使用 reasoning_effort,temperature 必须固定为 1(不传) // Logan.Li
payload.ReasoningEffort = "medium"
payload.Temperature = 0
return
}
if isGenericReasoningModel(payload.Model) {
payload.Reasoning = true
}
}
// 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"`
Stream bool `json:"stream,omitempty"` // 流式补全(Stream 方法置 true)
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"`
Stream bool `json:"stream,omitempty"` // 流式补全(Stream 方法置 true)
ReasoningEffort string `json:"reasoning_effort,omitempty"` // OpenAI o-series 推理强度 // Logan.Li
Reasoning bool `json:"reasoning,omitempty"` // DeepSeek 等部分平台推理开关 // Logan.Li
}
// openaiResponse OpenAI chat/completions 响应体 // Logan.Li
@@ -89,6 +120,7 @@ func (c *OpenAIClient) Complete(ctx context.Context, req *CompletionRequest) (*C
if payload.MaxTokens <= 0 {
payload.MaxTokens = 4096
}
applyReasoningParams(&payload, req.ReasoningEnabled || c.cfg.ReasoningEnabled)
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("序列化请求失败: %w", err)
@@ -151,6 +183,7 @@ func (c *OpenAIClient) Stream(ctx context.Context, req *CompletionRequest, onDel
if payload.MaxTokens <= 0 {
payload.MaxTokens = 4096
}
applyReasoningParams(&payload, req.ReasoningEnabled || c.cfg.ReasoningEnabled)
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("序列化请求失败: %w", err)
+5 -4
View File
@@ -43,10 +43,11 @@ type ToolFunction struct {
// 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"`
Messages []Message `json:"messages"`
Tools []Tool `json:"tools,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
ReasoningEnabled bool `json:"reasoning_enabled,omitempty"` // 是否启用推理/Plan 模式(协议相关) // Logan.Li
}
// CompletionResponse 补全响应(取首个 choice) // Logan.Li
+11
View File
@@ -153,6 +153,17 @@ func StringParam(args map[string]interface{}, key string) string {
return ""
}
// Close 关闭所有实现了 Close() 的工具(如 ssh_execute 长连接) // Logan.Li
func (r *Registry) Close() {
r.mu.RLock()
defer r.mu.RUnlock()
for _, t := range r.tools {
if c, ok := t.(interface{ Close() error }); ok {
_ = c.Close()
}
}
}
// IntParam 取整型参数(兼容 int/float64/int64) // Logan.Li
func IntParam(args map[string]interface{}, key string) int {
if v, ok := args[key]; ok {
+24 -18
View File
@@ -1,5 +1,6 @@
// ssh_execute.go - 远程命令执行工具 // Logan.Li
// 复用 aiops 既有 pkg/remoteexec(Go 原生 SSH),凭据由 controller 从 model.Server 构建后注入。
// 任务级复用 PersistentClient 长连接,避免每条命令重建 SSH 握手。
// 实现 DynamicRiskTool:依据具体命令动态评估风险,决定是否触发用户确认。
package tools
@@ -14,16 +15,28 @@ import (
// SSHExecuteTool 远程命令执行工具 // Logan.Li
type SSHExecuteTool struct {
cfg remoteexec.SSHConfig // 目标主机连接配置(含凭据)
timeout time.Duration // 默认命令超时
client *remoteexec.PersistentClient // 任务级 SSH 长连接
timeout time.Duration // 默认命令超时
}
// NewSSHExecuteTool 创建远程执行工具 // Logan.Li
func NewSSHExecuteTool(cfg remoteexec.SSHConfig, timeout time.Duration) *SSHExecuteTool {
// NewSSHExecuteTool 创建远程执行工具(内部建立 SSH 长连接) // Logan.Li
func NewSSHExecuteTool(cfg remoteexec.SSHConfig, timeout time.Duration) (*SSHExecuteTool, error) {
if timeout <= 0 {
timeout = 60 * time.Second
}
return &SSHExecuteTool{cfg: cfg, timeout: timeout}
client, err := remoteexec.NewPersistentClient(cfg)
if err != nil {
return nil, fmt.Errorf("SSH 长连接建立失败: %w", err)
}
return &SSHExecuteTool{client: client, timeout: timeout}, nil
}
// Close 释放 SSH 长连接 // Logan.Li
func (t *SSHExecuteTool) Close() error {
if t.client == nil {
return nil
}
return t.client.Close()
}
// Name 工具名 // Logan.Li
@@ -31,14 +44,9 @@ 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)
return "在远程目标服务器上执行 shell 命令并返回合并的 stdout+stderr 输出与退出码。" +
"用于排障(查看日志/进程/磁盘/网络)、安装与配置软件、重启服务等。" +
"高危命令(rm -rf /、mkfs、reboot 等)需用户确认。"
}
// Parameters JSON Schema 参数定义 // Logan.Li
@@ -79,13 +87,11 @@ func (t *SSHExecuteTool) Execute(ctx context.Context, args map[string]interface{
timeout = time.Duration(sec) * time.Second
}
res, err := remoteexec.Exec(t.cfg, command, timeout)
res, err := t.client.Exec(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"
@@ -93,8 +99,8 @@ func (t *SSHExecuteTool) Execute(ctx context.Context, args map[string]interface{
output += fmt.Sprintf("[exit_code=%d]", res.ExitCode)
return &Result{
Success: res.Success, // exit_code==0
Success: res.Success,
Output: output,
Error: res.Error,
}, nil
}
}
+1
View File
@@ -120,6 +120,7 @@ func AutoMigrate(db *gorm.DB) error {
&model.LogEntry{},
&model.TopologyLayout{}, // 拓扑布局保存 // Logan.Li
&model.TerminalSession{}, // 终端会话录制(终端审计) // Logan.Li
&model.SessionCommand{}, // 终端会话命令审计 // Logan.Li
&model.LLMConfig{}, // 大语言模型配置(AI 智能助手) // Logan.Li
&model.ChatHistory{}, // 对话历史记录(按用户隔离) // Logan.Li
&model.MetricsAggregate{}, // 指标时序聚合(Redis → MySQL)// Logan.Li
+2 -69
View File
@@ -5,7 +5,6 @@ package remoteexec
import (
"bytes"
"context"
"fmt"
"log"
"net"
@@ -70,10 +69,7 @@ type UploadResult struct {
// 返回合并的 stdout+stderr 输出、退出码和错误。
func Exec(cfg SSHConfig, command string, timeout time.Duration) (*ExecResult, error) {
start := time.Now()
result := &ExecResult{
Host: cfg.Host,
Port: cfg.Port,
}
result := &ExecResult{Host: cfg.Host, Port: cfg.Port}
client, err := connect(cfg)
if err != nil {
@@ -83,70 +79,7 @@ func Exec(cfg SSHConfig, command string, timeout time.Duration) (*ExecResult, er
}
defer client.Close()
// 创建 session
session, err := client.NewSession()
if err != nil {
result.Duration = time.Since(start).Milliseconds()
result.Error = fmt.Sprintf("创建SSH会话失败: %v", err)
return result, err
}
defer session.Close()
// 设置超时
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
// 在 goroutine 中执行,支持超时
type execOut struct {
output []byte
err error
}
ch := make(chan execOut, 1)
go func() {
var stdout, stderr bytes.Buffer
session.Stdout = &stdout
session.Stderr = &stderr
err := session.Run(command)
// 合并输出
output := stdout.Bytes()
if stderr.Len() > 0 {
if len(output) > 0 {
output = append(output, '\n')
}
output = append(output, stderr.Bytes()...)
}
ch <- execOut{output: output, err: err}
}()
select {
case <-ctx.Done():
result.Duration = time.Since(start).Milliseconds()
result.Error = fmt.Sprintf("执行超时(%v)", timeout)
result.ExitCode = -1
return result, fmt.Errorf("exec timeout after %v", timeout)
case out := <-ch:
result.Duration = time.Since(start).Milliseconds()
result.Output = string(out.output)
if out.err != nil {
if exitErr, ok := out.err.(*ssh.ExitError); ok {
result.ExitCode = exitErr.ExitStatus()
result.Success = false
} else {
result.ExitCode = -1
result.Error = out.err.Error()
result.Success = false
return result, out.err
}
} else {
result.ExitCode = 0
result.Success = true
}
}
return result, nil
return execWithClient(client, cfg, command, timeout)
}
// BatchExec 在多台远程主机上并发执行相同命令。
+144
View File
@@ -0,0 +1,144 @@
// persistent_client.go - 任务级 SSH 长连接,多次 Exec 复用同一 client // Logan.Li
package remoteexec
import (
"bytes"
"context"
"fmt"
"sync"
"time"
"golang.org/x/crypto/ssh"
)
// PersistentClient 可复用的 SSH 长连接(任务生命周期内持有,Close 释放) // Logan.Li
type PersistentClient struct {
cfg SSHConfig
client *ssh.Client
mu sync.Mutex
}
// NewPersistentClient 建立 SSH 长连接 // Logan.Li
func NewPersistentClient(cfg SSHConfig) (*PersistentClient, error) {
client, err := connect(cfg)
if err != nil {
return nil, err
}
return &PersistentClient{cfg: cfg, client: client}, nil
}
// Exec 在长连接上执行命令;连接断开时自动重连一次 // Logan.Li
func (p *PersistentClient) Exec(command string, timeout time.Duration) (*ExecResult, error) {
p.mu.Lock()
defer p.mu.Unlock()
result, err := p.execLocked(command, timeout)
if err == nil {
return result, nil
}
// 连接级失败:重连后重试一次 // Logan.Li
if p.client != nil {
_ = p.client.Close()
p.client = nil
}
client, connErr := connect(p.cfg)
if connErr != nil {
return result, err
}
p.client = client
return p.execLocked(command, timeout)
}
// execLocked 假定已持锁,在当前 client 上执行 // Logan.Li
func (p *PersistentClient) execLocked(command string, timeout time.Duration) (*ExecResult, error) {
if p.client == nil {
client, err := connect(p.cfg)
if err != nil {
return nil, err
}
p.client = client
}
return execWithClient(p.client, p.cfg, command, timeout)
}
// Close 关闭长连接 // Logan.Li
func (p *PersistentClient) Close() error {
p.mu.Lock()
defer p.mu.Unlock()
if p.client == nil {
return nil
}
err := p.client.Close()
p.client = nil
return err
}
// execWithClient 在已有 SSH client 上执行命令(供 Exec / PersistentClient 共用) // Logan.Li
func execWithClient(client *ssh.Client, cfg SSHConfig, command string, timeout time.Duration) (*ExecResult, error) {
start := time.Now()
result := &ExecResult{Host: cfg.Host, Port: cfg.Port}
session, err := client.NewSession()
if err != nil {
result.Duration = time.Since(start).Milliseconds()
result.Error = fmt.Sprintf("创建SSH会话失败: %v", err)
return result, err
}
defer session.Close()
if timeout <= 0 {
timeout = 60 * time.Second
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
type execOut struct {
output []byte
err error
}
ch := make(chan execOut, 1)
go func() {
var stdout, stderr bytes.Buffer
session.Stdout = &stdout
session.Stderr = &stderr
runErr := session.Run(command)
output := stdout.Bytes()
if stderr.Len() > 0 {
if len(output) > 0 {
output = append(output, '\n')
}
output = append(output, stderr.Bytes()...)
}
ch <- execOut{output: output, err: runErr}
}()
select {
case <-ctx.Done():
result.Duration = time.Since(start).Milliseconds()
result.Error = fmt.Sprintf("执行超时(%v)", timeout)
result.ExitCode = -1
return result, fmt.Errorf("exec timeout after %v", timeout)
case out := <-ch:
result.Duration = time.Since(start).Milliseconds()
result.Output = string(out.output)
if out.err != nil {
if exitErr, ok := out.err.(*ssh.ExitError); ok {
result.ExitCode = exitErr.ExitStatus()
result.Success = false
} else {
result.ExitCode = -1
result.Error = out.err.Error()
result.Success = false
return result, out.err
}
} else {
result.ExitCode = 0
result.Success = true
}
}
return result, nil
}