Files
aiops/backend/controller/agent_cli.go
T
Logan.Li 1c98c25c74 feat(agentcli): 运行结果 incomplete 语义与契约文档
RunResult 增加 Outcome/Reason;触顶标记 incomplete:max_steps;
WebSocket finish 区分 incomplete 与 fatal error。补充 ai-agent docs 13 与挂点索引。
2026-10-19 03:53:09 +08:00

797 lines
26 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// agent_cli.go - Agent CLI WebSocket 控制器 // Logan.Li
// 驱动 pkg/agentcli 引擎:主机模式(SSH)或 K8s 模式(集群查询/Pod 执行)→ ReAct 循环,
// 通过 WebSocket 实时推送思考/工具调用/输出/结果,高危命令经前端确认,过程持久化到 DB。
package controller
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"gorm.io/gorm"
"ezops/middleware"
"ezops/model"
"ezops/pkg/agentcli"
"ezops/pkg/agentcli/core"
"ezops/pkg/agentcli/guardrail"
"ezops/pkg/agentcli/llm"
"ezops/pkg/agentcli/safety"
"ezops/pkg/agentcli/tools"
"ezops/pkg/aiopscontext"
"ezops/pkg/remoteexec"
)
// AgentCLIHandler Agent CLI 控制器 // Logan.Li
type AgentCLIHandler struct {
db *gorm.DB
}
// NewAgentCLIHandler 创建控制器 // Logan.Li
func NewAgentCLIHandler(db *gorm.DB) *AgentCLIHandler { return &AgentCLIHandler{db: db} }
// wsEvent WebSocket 事件信封(后端→前端) // Logan.Li
type wsEvent struct {
Type string `json:"type"`
Phase string `json:"phase,omitempty"`
Summary string `json:"summary,omitempty"`
Round int `json:"round,omitempty"`
SessionID uint `json:"session_id,omitempty"`
Thought string `json:"thought,omitempty"`
ToolName string `json:"tool_name,omitempty"`
ToolArgs map[string]interface{} `json:"tool_args,omitempty"`
Risk string `json:"risk,omitempty"`
Output string `json:"output,omitempty"`
Success bool `json:"success,omitempty"`
Duration string `json:"duration,omitempty"`
OutputSize int `json:"output_size,omitempty"`
Tokens int `json:"tokens,omitempty"`
Answer string `json:"answer,omitempty"`
Message string `json:"message,omitempty"`
ConfirmID string `json:"confirm_id,omitempty"`
Command string `json:"command,omitempty"`
}
// wsControl 前端→后端控制消息 // Logan.Li
type wsControl struct {
Type string `json:"type"` // start/continue/resume/confirm/deny/abort
Query string `json:"query,omitempty"` // start/continue 时任务描述
SessionID uint `json:"session_id,omitempty"` // resume 时恢复的历史会话 ID
SafetyMode string `json:"safety_mode,omitempty"` // strict/balanced/permissive
ConfirmID string `json:"confirm_id,omitempty"` // confirm/deny 时
}
// writeWSCloseError 在 agentConn 建立前,直接通过 conn 推送一条 error 事件再关闭连接 // Logan.Li
// 用于 WS 升级后的预检失败(SSH 凭据/LLM 配置),让前端能拿到具体原因而非"一直转圈"
func writeWSCloseError(conn *websocket.Conn, msg string) {
_ = conn.WriteJSON(wsEvent{Type: "error", Message: msg})
// 留出短暂窗口让浏览器接收 error 帧,再发送 close 帧:否则 close 帧紧随其后、叠加
// defer conn.Close() 触发的 TCP 关闭,可能令 error 帧被丢弃,前端表现就是"正在连接主机"
// 永久转圈、看不到真实原因 // Logan.Li
time.Sleep(120 * time.Millisecond)
_ = conn.WriteMessage(websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseNormalClosure, msg))
}
// agentConn 单个 WS 连接的运行态 // Logan.Li
type agentConn struct {
conn *websocket.Conn
db *gorm.DB
userID uint
mode string
server model.Server
sshCfg remoteexec.SSHConfig
kubernetesCtx *kubernetesAgentContext
session *model.AIRun
taskCtx context.Context
cancel context.CancelFunc
totalTokens int
tokenMu sync.Mutex
writeMu sync.Mutex
confirms map[string]chan bool
cfMu sync.Mutex
convHistory []llm.Message
running bool
runningMu sync.Mutex
pendingSpans map[string]string
seq int
seqMu sync.Mutex
}
// Run WebSocket 入口 // Logan.Li
// 主机: GET /ws/agent-cli?mode=host&id=<serverID>&token=<jwt>
// Kubernetes: GET /ws/agent-cli?mode=kubernetes&cluster=<code>&namespace=<ns>&pod=<可选>&container=<可选>&token=<jwt>
func (h *AgentCLIHandler) Run(c *gin.Context) {
mode := agentcli.NormalizeRunMode(c.DefaultQuery("mode", agentcli.RunModeHost))
if mode == agentcli.RunModeKubernetes {
h.runKubernetesWS(c)
return
}
h.runHostWS(c)
}
// runHostWS 主机模式 WebSocket // Logan.Li
func (h *AgentCLIHandler) runHostWS(c *gin.Context) {
serverID := c.Query("id")
if serverID == "" {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "缺少主机 id"})
return
}
var server model.Server
if err := h.db.First(&server, serverID).Error; err != nil {
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "主机不存在"})
return
}
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
defer conn.Close()
sshCfg, err := buildSSHConfig(h.db, server)
if err != nil {
writeWSCloseError(conn, "SSH 连接失败: "+err.Error())
return
}
var llmCfg model.LLMConfig
if err := h.db.First(&llmCfg).Error; err != nil || !llmCfg.Enabled {
writeWSCloseError(conn, "LLM 服务未配置或未启用,请先在「大模型设置」中配置")
return
}
ac := &agentConn{
conn: conn, db: h.db, mode: agentcli.RunModeHost,
userID: middleware.GetUserID(c), server: server, sshCfg: sshCfg,
taskCtx: context.Background(), confirms: make(map[string]chan bool), pendingSpans: make(map[string]string),
}
ac.write(wsEvent{Type: "session", SessionID: 0,
Message: fmt.Sprintf("已连接主机 %s(%s),LLM: %s。请描述要执行的任务。", server.Hostname, server.IP, llmCfg.Model)})
ac.wsReadLoop()
}
// runKubernetesWS Kubernetes 模式 WebSocket // Logan.Li
func (h *AgentCLIHandler) runKubernetesWS(c *gin.Context) {
clusterCode, namespace, podName, container := parseKubernetesWSParams(c)
if clusterCode == "" || namespace == "" {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "缺少 cluster 或 namespace 参数"})
return
}
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
defer conn.Close()
kCtx, err := loadKubernetesAgentContext(h.db, clusterCode, namespace, podName)
if err != nil {
writeWSCloseError(conn, "Kubernetes 连接失败: "+err.Error())
return
}
kCtx.container = container
var llmCfg model.LLMConfig
if err := h.db.First(&llmCfg).Error; err != nil || !llmCfg.Enabled {
writeWSCloseError(conn, "LLM 服务未配置或未启用,请先在「大模型设置」中配置")
return
}
ac := &agentConn{
conn: conn, db: h.db, mode: agentcli.RunModeKubernetes,
userID: middleware.GetUserID(c), kubernetesCtx: kCtx,
taskCtx: context.Background(), confirms: make(map[string]chan bool), pendingSpans: make(map[string]string),
}
target := buildKubernetesTargetInfo(kCtx)
ac.write(wsEvent{Type: "session", SessionID: 0,
Message: fmt.Sprintf("已连接 Kubernetes 集群,LLM: %s。\n%s\n请描述要执行的任务。", llmCfg.Model, target)})
ac.wsReadLoop()
}
// wsReadLoop 处理前端控制消息 // Logan.Li
func (ac *agentConn) wsReadLoop() {
for {
_, data, err := ac.conn.ReadMessage()
if err != nil {
if ac.isRunning() {
ac.abort()
}
return
}
var ctrl wsControl
if json.Unmarshal(data, &ctrl) != nil {
continue
}
switch ctrl.Type {
case "start":
if ctrl.Query == "" {
ac.write(wsEvent{Type: "error", Message: "任务描述不能为空"})
continue
}
ac.startTask(ctrl.Query, ctrl.SafetyMode)
case "continue":
if ctrl.Query == "" {
ac.write(wsEvent{Type: "error", Message: "追问内容不能为空"})
continue
}
ac.continueTask(ctrl.Query, ctrl.SafetyMode)
case "resume":
if ctrl.SessionID == 0 {
ac.write(wsEvent{Type: "error", Message: "缺少 session_id"})
continue
}
ac.resumeSession(ctrl.SessionID)
case "confirm":
ac.resolveConfirm(ctrl.ConfirmID, true)
case "deny":
ac.resolveConfirm(ctrl.ConfirmID, false)
case "abort":
ac.abort()
}
}
}
// parseSafetyMode 解析前端传入的安全模式 // Logan.Li
func parseSafetyMode(mode string) safety.Mode {
switch mode {
case "strict":
return safety.ModeStrict
case "permissive":
return safety.ModePermissive
default:
return safety.ModeBalanced
}
}
// startTask 启动新任务(新会话,清空对话历史) // Logan.Li
func (ac *agentConn) startTask(query, safetyMode string) {
ac.abort()
var llmCfg model.LLMConfig
if err := ac.db.First(&llmCfg).Error; err != nil || !llmCfg.Enabled {
ac.write(wsEvent{Type: "error", Message: "LLM 服务未配置或未启用,请先在「大模型设置」中配置"})
return
}
title := query
if r := []rune(title); len(r) > 60 {
title = string(r[:60]) + "..."
}
sess := &model.AIRun{
RunID: guardrail.NewTraceID(), UserID: ac.userID, Feature: "agent_cli",
Mode: ac.mode, Title: title, Status: "running",
Provider: llmCfg.Provider, Model: llmCfg.Model,
SafetyMode: safetyMode, StartedAt: time.Now(),
}
if ac.mode == agentcli.RunModeKubernetes && ac.kubernetesCtx != nil {
sess.SubjectType = "k8s_cluster"
if ac.kubernetesCtx.podName != "" {
sess.SubjectType = "pod"
} else if ac.kubernetesCtx.namespace != "" {
sess.SubjectType = "namespace"
}
sess.SubjectID = fmt.Sprintf("%d", ac.kubernetesCtx.cluster.ID)
sess.SubjectName = ac.kubernetesCtx.cluster.Name
sess.SubjectScope = ac.kubernetesCtx.namespace
} else {
sess.SubjectType = "host"
sess.SubjectID = fmt.Sprintf("%d", ac.server.ID)
sess.SubjectName = ac.server.Hostname
sess.SubjectScope = ac.server.IP
}
if err := ac.db.Create(sess).Error; err != nil {
ac.write(wsEvent{Type: "error", Message: "创建会话失败: " + err.Error()})
return
}
ac.session = sess
ac.convHistory = nil
ac.setTotalTokens(0)
ac.runTask(sess, query, safetyMode, llmCfg, false)
}
// resumeSession 从历史会话恢复上下文,供继续追问 // Logan.Li
func (ac *agentConn) resumeSession(sessionID uint) {
if ac.isRunning() {
ac.write(wsEvent{Type: "error", Message: "任务进行中,请等待完成或停止"})
return
}
var sess model.AIRun
q := ac.db.Where("user_id = ?", ac.userID)
if ac.mode == agentcli.RunModeKubernetes {
q = q.Where("mode IN ?", []string{agentcli.RunModeKubernetes, "k8s"})
if ac.kubernetesCtx != nil {
q = q.Where("subject_id = ? AND subject_scope = ?", fmt.Sprintf("%d", ac.kubernetesCtx.cluster.ID), ac.kubernetesCtx.namespace)
}
} else {
q = q.Where("mode = ? AND subject_type = ? AND subject_id = ?", ac.mode, "host", fmt.Sprintf("%d", ac.server.ID))
}
if err := q.First(&sess, sessionID).Error; err != nil {
ac.write(wsEvent{Type: "error", Message: "会话不存在或无权访问"})
return
}
if sess.Status == "running" {
ac.write(wsEvent{Type: "error", Message: "该会话仍在执行中,无法恢复"})
return
}
var msgs []model.AIRunMessage
ac.db.Where("run_id = ?", sess.RunID).Order("seq ASC, id ASC").Find(&msgs)
ac.session = &sess
ac.convHistory = buildConvHistoryFromMessages(msgs)
ac.setTotalTokens(sess.TotalTokens)
ac.write(wsEvent{
Type: "session", SessionID: sess.ID,
Message: fmt.Sprintf("已恢复历史会话「%s」,可继续追问或执行新指令", sess.Title),
})
}
// continueTask 在同一会话内追问(保留上下文) // Logan.Li
func (ac *agentConn) continueTask(query, safetyMode string) {
if ac.isRunning() {
ac.write(wsEvent{Type: "error", Message: "任务进行中,请等待完成或停止"})
return
}
if ac.session == nil {
ac.write(wsEvent{Type: "error", Message: "请先开始新任务"})
return
}
if ac.session.Status == "running" {
ac.write(wsEvent{Type: "error", Message: "当前会话仍在执行中"})
return
}
var llmCfg model.LLMConfig
if err := ac.db.First(&llmCfg).Error; err != nil || !llmCfg.Enabled {
ac.write(wsEvent{Type: "error", Message: "LLM 服务未配置或未启用,请先在「大模型设置」中配置"})
return
}
ac.runTask(ac.session, query, safetyMode, llmCfg, true)
}
// runTask 执行一轮 ReAct(新建或追问共用) // Logan.Li
func (ac *agentConn) runTask(sess *model.AIRun, query, safetyMode string, llmCfg model.LLMConfig, isContinue bool) {
if ac.isRunning() {
ac.write(wsEvent{Type: "error", Message: "任务进行中,请等待完成或停止"})
return
}
prior := ac.convHistory
priorTokens := ac.getTotalTokens()
roundOffset := 0
if !isContinue {
prior = nil
priorTokens = 0
} else {
roundOffset = ac.lastRound()
}
coreCfg := core.DefaultConfig()
coreCfg.RoundOffset = roundOffset
coreCfg.Mode = ac.mode
engCfg := agentcli.EngineConfig{
Mode: ac.mode,
LLM: buildLLMConfigFromModel(llmCfg),
SafetyMode: parseSafetyMode(safetyMode),
MemoryDir: fmt.Sprintf("data/agentcli/user_%d", ac.userID),
Core: coreCfg,
ExtraTools: aiopscontext.NewAgentTools(aiopscontext.NewService(ac.db)),
}
if ac.mode == agentcli.RunModeKubernetes {
if ac.kubernetesCtx == nil {
ac.write(wsEvent{Type: "error", Message: "Kubernetes 上下文丢失"})
return
}
engCfg.Kubernetes = &agentcli.KubernetesTarget{
Client: ac.kubernetesCtx.client, ClusterName: ac.kubernetesCtx.cluster.Name,
Namespace: ac.kubernetesCtx.namespace, PodName: ac.kubernetesCtx.podName, Container: ac.kubernetesCtx.container,
}
} else {
engCfg.SSH = ac.sshCfg
}
engine, err := agentcli.NewEngine(engCfg)
if err != nil {
ac.write(wsEvent{Type: "error", Message: "引擎初始化失败: " + err.Error()})
ac.finish(sess, "error", "引擎初始化失败: "+err.Error(), ac.lastRound(), 0)
return
}
engine.SetCallback(ac.onEvent)
engine.SetConfirmFn(ac.confirmFn)
ctx, cancel := context.WithCancel(context.Background())
ac.taskCtx = ctx
ac.cancel = cancel
ac.setRunning(true)
ac.db.Model(sess).Updates(map[string]interface{}{"status": "running", "safety_mode": safetyMode})
ac.persistMsg(sess.RunID, 0, "user", "user", query, "", "", "", "", false, "", 0)
targetInfo := ac.buildTargetInfo()
msg := "任务已开始"
if isContinue {
msg = "继续追问"
}
ac.write(wsEvent{Type: "session", SessionID: sess.ID, Message: msg})
go func() {
defer func() {
ac.setRunning(false)
engine.Close()
}()
start := time.Now()
result, err := engine.Run(ctx, query, targetInfo, prior, priorTokens)
if result != nil && len(result.History) > 0 {
ac.convHistory = result.History
ac.setTotalTokens(result.TotalTokens)
}
dur := time.Since(start)
if ctx.Err() != nil {
ac.finish(sess, "aborted", "任务已中断", ac.lastRound(), dur)
} else if result != nil && result.Outcome == "incomplete" {
// 契约 13:步数/预算触顶为 incomplete,可续跑,不与 fatal error 混用 // Logan.Li
msg := result.Output
if msg == "" && err != nil {
msg = err.Error()
}
ac.finish(sess, "incomplete", msg, ac.lastRound(), dur)
} else if err != nil {
ac.finish(sess, "error", err.Error(), ac.lastRound(), dur)
} else {
ac.finish(sess, "done", "", ac.lastRound(), dur)
}
}()
}
func (ac *agentConn) isRunning() bool {
ac.runningMu.Lock()
defer ac.runningMu.Unlock()
return ac.running
}
func (ac *agentConn) setRunning(v bool) {
ac.runningMu.Lock()
ac.running = v
ac.runningMu.Unlock()
}
// setTotalTokens 更新当前任务 token 计数 // Logan.Li
func (ac *agentConn) setTotalTokens(n int) {
ac.tokenMu.Lock()
ac.totalTokens = n
ac.tokenMu.Unlock()
}
func (ac *agentConn) getTotalTokens() int {
ac.tokenMu.Lock()
defer ac.tokenMu.Unlock()
return ac.totalTokens
}
// onEvent 引擎事件回调:推 WS + 持久化 // Logan.Li
func (ac *agentConn) onEvent(e core.Event) {
if e.Type == core.EventProgress && e.Tokens > 0 {
ac.setTotalTokens(e.Tokens)
}
if e.Type == core.EventDone || e.Type == core.EventError {
ac.setRunning(false)
}
ev := wsEvent{
Type: string(e.Type), Phase: e.Phase, Summary: e.Summary,
Round: e.Round, Thought: e.Thought,
ToolName: e.ToolName, ToolArgs: e.ToolArgs, Risk: e.Risk,
Output: e.Output, Success: e.Success, Duration: e.Duration, OutputSize: e.OutputSize,
Tokens: e.Tokens, Answer: e.Answer, Message: e.Message,
}
if ac.session != nil {
ev.SessionID = ac.session.ID
}
ac.write(ev)
// 持久化(progress / thought_delta 太频繁不入库) // Logan.Li
if ac.session == nil {
return
}
runID := ac.session.RunID
switch e.Type {
case core.EventStatus:
// status 事件不改 DB schema:duration 字段在 type=status 时复用为 phase。
ac.persistMsg(runID, e.Round, "status", "system", e.Summary, e.ToolName, "", "", e.Risk, e.Phase != core.PhaseError && e.Phase != core.PhaseBlocked, e.Phase, e.Tokens)
case core.EventThought:
ac.persistMsg(runID, e.Round, "thought", "assistant", e.Thought, "", "", "", "", false, "", 0)
case core.EventToolCall:
argsJSON, _ := json.Marshal(e.ToolArgs)
ac.persistMsg(runID, e.Round, "tool_call", "tool", string(argsJSON), e.ToolName, string(argsJSON), "", e.Risk, false, "", 0)
ac.startToolSpan(e, string(argsJSON))
case core.EventToolResult:
ac.persistMsg(runID, e.Round, "tool_result", "tool", e.Output, e.ToolName, "", "", "", e.Success, e.Duration, 0)
ac.finishToolSpan(e)
case core.EventDone:
ac.setTotalTokens(e.Tokens)
ac.persistMsg(runID, e.Round, "done", "assistant", e.Answer, "", "", "", "", true, "", e.Tokens)
case core.EventError:
ac.persistMsg(runID, e.Round, "error", "assistant", e.Message, "", "", "", "", false, "", 0)
}
}
// buildTargetInfo 构建 Prompt 目标上下文 // Logan.Li
func (ac *agentConn) buildTargetInfo() string {
if ac.mode == agentcli.RunModeKubernetes && ac.kubernetesCtx != nil {
return buildKubernetesTargetInfo(ac.kubernetesCtx)
}
return fmt.Sprintf("主机名: %s | IP: %s:%d | 用户: %s | OS: %s",
ac.server.Hostname, ac.server.IP, ac.server.Port, ac.server.Username, ac.server.OS)
}
// confirmFn 高危命令确认回调:推 confirm 事件并阻塞等前端回传 // Logan.Li
func (ac *agentConn) confirmFn(toolName string, args map[string]interface{}, risk tools.RiskLevel) (bool, error) {
safeArgs := guardrail.SanitizeMap(args)
confirmID := fmt.Sprintf("cf-%d", time.Now().UnixNano())
cmd, _ := safeArgs["command"].(string)
if cmd == "" && (toolName == "kubernetes_action" || toolName == "k8s_action") {
cmd = fmt.Sprintf("%v %v", safeArgs["action"], safeArgs["name"])
}
ac.write(wsEvent{Type: "confirm", Phase: core.PhaseApproval, Summary: "等待用户确认", ConfirmID: confirmID, ToolName: toolName, Command: cmd, Risk: risk.Display(), ToolArgs: safeArgs})
ch := make(chan bool, 1)
ac.cfMu.Lock()
ac.confirms[confirmID] = ch
ac.cfMu.Unlock()
select {
case approved := <-ch:
ac.recordConfirmSpan(toolName, safeArgs, risk, approved)
return approved, nil
case <-ac.taskCtx.Done():
ac.delConfirm(confirmID)
return false, ac.taskCtx.Err()
case <-time.After(5 * time.Minute):
ac.delConfirm(confirmID)
return false, fmt.Errorf("确认超时")
}
}
func (ac *agentConn) recordConfirmSpan(toolName string, args map[string]interface{}, risk tools.RiskLevel, approved bool) {
if ac.session == nil {
return
}
decision := "rejected"
status := "skipped"
if approved {
decision = "approved"
status = "success"
}
argsJSON, _ := json.Marshal(args)
span := &model.AIRunSpan{
RunID: ac.session.RunID, SpanID: guardrail.NewSpanID(), Kind: "confirm", Name: toolName,
ToolSource: ac.toolSource(toolName), Input: guardrail.SanitizeJSON(string(argsJSON)),
Risk: risk.String(), Decision: decision, Status: status, StartedAt: time.Now(), CreatedAt: time.Now(),
}
_ = ac.db.Create(span).Error
}
// resolveConfirm 前端回传确认结果 // Logan.Li
func (ac *agentConn) resolveConfirm(confirmID string, approved bool) {
ac.cfMu.Lock()
ch, ok := ac.confirms[confirmID]
if ok {
delete(ac.confirms, confirmID)
}
ac.cfMu.Unlock()
if ok {
ch <- approved
}
}
func (ac *agentConn) delConfirm(id string) {
ac.cfMu.Lock()
delete(ac.confirms, id)
ac.cfMu.Unlock()
}
// abort 中断当前任务并唤醒所有等待中的确认 // Logan.Li
func (ac *agentConn) abort() {
if ac.cancel != nil {
ac.cancel()
ac.cancel = nil
}
ac.cfMu.Lock()
for id, ch := range ac.confirms {
select {
case ch <- false:
default:
}
delete(ac.confirms, id)
}
ac.cfMu.Unlock()
}
// lastRound 估算已完成轮次(从 DB 取该会话最大 round) // Logan.Li
func (ac *agentConn) lastRound() int {
if ac.session == nil {
return 0
}
var maxRound int
ac.db.Model(&model.AIRunMessage{}).Where("run_id = ?", ac.session.RunID).
Select("COALESCE(MAX(round),0)").Scan(&maxRound)
return maxRound
}
// finish 结束本轮执行并更新会话 DB // Logan.Li
func (ac *agentConn) finish(sess *model.AIRun, status, errMsg string, rounds int, dur time.Duration) {
ac.setRunning(false)
tokens := ac.getTotalTokens()
// done/error 已由引擎 onEvent 推送;此处仅 aborted 需补推 WS(引擎初始化失败在 runTask 内单独推送) // Logan.Li
if status == "aborted" {
ac.write(wsEvent{Type: status, SessionID: sess.ID, Message: errMsg, Round: rounds, Tokens: tokens})
}
updates := map[string]interface{}{
"status": status, "rounds": rounds, "duration": dur.Milliseconds(),
"total_tokens": tokens,
}
if status == "done" || status == "error" || status == "aborted" {
now := time.Now()
updates["finished_at"] = &now
}
if errMsg != "" {
updates["error"] = errMsg
}
ac.db.Model(sess).Updates(updates)
}
// write 线程安全写 WS 消息 // Logan.Li
func (ac *agentConn) write(ev wsEvent) {
ac.writeMu.Lock()
defer ac.writeMu.Unlock()
b, _ := json.Marshal(ev)
_ = ac.conn.WriteMessage(websocket.TextMessage, b)
}
// persistMsg 持久化一条 AI Run 回放消息。 // Logan.Li
func (ac *agentConn) persistMsg(runID string, round int, typ, role, content, toolName, args, output, risk string, success bool, dur string, tokens int) {
content = guardrail.SanitizeText(content)
if typ == "tool_call" {
content = guardrail.SanitizeJSON(content)
}
msg := &model.AIRunMessage{
RunID: runID, Round: round, Seq: ac.nextSeq(),
Type: typ, Role: role, Content: content,
ToolName: toolName, Tokens: tokens,
}
if err := ac.db.Create(msg).Error; err != nil {
// 持久化失败不阻断主流程
_ = err
}
}
func (ac *agentConn) nextSeq() int {
ac.seqMu.Lock()
defer ac.seqMu.Unlock()
ac.seq++
return ac.seq
}
func (ac *agentConn) startToolSpan(e core.Event, argsJSON string) {
if ac.session == nil {
return
}
spanID := guardrail.NewSpanID()
key := ac.pendingSpanKey(e.Round, e.ToolName)
ac.runningMu.Lock()
ac.pendingSpans[key] = spanID
ac.runningMu.Unlock()
span := &model.AIRunSpan{
RunID: ac.session.RunID, SpanID: spanID, Kind: "tool", Name: e.ToolName,
ToolSource: ac.toolSource(e.ToolName), Input: guardrail.SanitizeJSON(argsJSON),
Risk: e.Risk, Decision: "auto", Status: "running",
StartedAt: time.Now(),
}
_ = ac.db.Create(span).Error
}
func (ac *agentConn) finishToolSpan(e core.Event) {
if ac.session == nil {
return
}
key := ac.pendingSpanKey(e.Round, e.ToolName)
ac.runningMu.Lock()
spanID := ac.pendingSpans[key]
delete(ac.pendingSpans, key)
ac.runningMu.Unlock()
if spanID == "" {
return
}
status := "success"
if !e.Success {
status = "error"
}
now := time.Now()
ac.db.Model(&model.AIRunSpan{}).Where("span_id = ?", spanID).Updates(map[string]interface{}{
"output": guardrail.SanitizeText(e.Output), "output_size": e.OutputSize,
"duration": parseDurationMillis(e.Duration), "status": status, "ended_at": &now,
})
ac.recordOpsCommand(e, spanID, status)
}
func (ac *agentConn) pendingSpanKey(round int, toolName string) string {
return fmt.Sprintf("%d:%s", round, toolName)
}
func (ac *agentConn) toolSource(toolName string) string {
switch toolName {
case "ssh_execute":
return "shell"
case "kubernetes_diagnose", "kubernetes_get", "pod_logs", "pod_execute", "kubernetes_action":
return "kubernetes"
case "aiops_asset_search", "aiops_alert_list", "aiops_metric_summary":
return "aiops"
default:
return "builtin"
}
}
func (ac *agentConn) recordOpsCommand(e core.Event, spanID, status string) {
if ac.session == nil {
return
}
command := e.ToolName
if e.ToolArgs != nil {
if cmd, ok := e.ToolArgs["command"].(string); ok && cmd != "" {
command = cmd
} else if action, ok := e.ToolArgs["action"]; ok {
command = fmt.Sprintf("%v %v", action, e.ToolArgs["name"])
}
}
targetType, targetID := ac.currentTarget()
cmd := &model.OpsCommand{
CommandID: guardrail.NewSpanID(), RunID: ac.session.RunID, SpanID: spanID,
UserID: ac.userID, ActorType: "ai", TargetType: targetType, TargetID: targetID,
CommandType: ac.commandType(e.ToolName), Command: guardrail.SanitizeText(command),
Risk: e.Risk, Decision: "auto", Status: status, OutputSize: e.OutputSize,
Duration: parseDurationMillis(e.Duration), ExecutedAt: time.Now(),
}
if !e.Success {
cmd.Error = guardrail.SanitizeText(e.Output)
}
_ = ac.db.Create(cmd).Error
}
func (ac *agentConn) currentTarget() (string, string) {
if ac.mode == agentcli.RunModeKubernetes && ac.kubernetesCtx != nil {
if ac.kubernetesCtx.podName != "" {
return "pod", fmt.Sprintf("%d/%s/%s", ac.kubernetesCtx.cluster.ID, ac.kubernetesCtx.namespace, ac.kubernetesCtx.podName)
}
return "k8s_cluster", fmt.Sprintf("%d", ac.kubernetesCtx.cluster.ID)
}
return "host", fmt.Sprintf("%d", ac.server.ID)
}
func (ac *agentConn) commandType(toolName string) string {
switch toolName {
case "ssh_execute", "pod_execute":
return "shell"
case "kubernetes_diagnose", "kubernetes_action", "kubernetes_get", "pod_logs":
return "kubernetes"
case "aiops_asset_search", "aiops_alert_list", "aiops_metric_summary":
return "api"
default:
return "tool"
}
}
func parseDurationMillis(raw string) int64 {
if raw == "" {
return 0
}
d, err := time.ParseDuration(raw)
if err != nil {
return 0
}
return d.Milliseconds()
}