- Agent 支持 SSE(步骤边推、正文分块),JSON 非流式 fallback - tool calling 覆盖 openai-chat / anthropic / responses(单测解析回灌) - P1:mcp.json mode=read_write 才可写;需 confirm_danger;Agent 剥离模型自带令牌 - GET /api/ai/mcp-status、POST /api/ai/agent/confirm;审计 source=mcp|agent
379 lines
11 KiB
Go
379 lines
11 KiB
Go
package ai
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
"opsxdb/server/internal/aitools"
|
||
)
|
||
|
||
const (
|
||
defaultAgentMaxRounds = 8
|
||
maxToolResultFeedChars = 12000
|
||
maxStepArgsChars = 200
|
||
maxGridRows = 200
|
||
)
|
||
|
||
// AgentReq 侧栏 Agent 请求(与 Ask 的 /api/ai/chat 分路径)。
|
||
type AgentReq struct {
|
||
Prompt string `json:"prompt"`
|
||
ConnectionID string `json:"connectionId,omitempty"`
|
||
Driver string `json:"driver,omitempty"`
|
||
Database string `json:"database,omitempty"`
|
||
Model string `json:"model,omitempty"`
|
||
MaxRounds int `json:"maxRounds,omitempty"`
|
||
Stream bool `json:"stream,omitempty"`
|
||
}
|
||
|
||
// AgentStep 挂在回复下的步骤时间线一项。
|
||
type AgentStep struct {
|
||
Tool string `json:"tool"`
|
||
Args string `json:"args,omitempty"`
|
||
OK bool `json:"ok"`
|
||
Summary string `json:"summary"`
|
||
Error string `json:"error,omitempty"`
|
||
Ms int64 `json:"ms"`
|
||
NeedConfirm bool `json:"needConfirm,omitempty"`
|
||
ConfirmSQL string `json:"confirmSql,omitempty"`
|
||
Pending bool `json:"pending,omitempty"`
|
||
Result *AgentQueryResult `json:"result,omitempty"`
|
||
}
|
||
|
||
// AgentRes Agent 最终回答 + 步骤。
|
||
type AgentRes struct {
|
||
Content string `json:"content"`
|
||
SQL string `json:"sql,omitempty"`
|
||
Steps []AgentStep `json:"steps"`
|
||
Rounds int `json:"rounds"`
|
||
}
|
||
|
||
// RunAgent 多轮:LLM tool_call → aitools.Runtime.Call → 回灌。
|
||
// ConfirmDanger 永不由模型自动带上(execOneTool 会剥离 confirm_danger)。
|
||
func RunAgent(ctx context.Context, llm ToolChat, rt *aitools.Runtime, req AgentReq) (AgentRes, error) {
|
||
return runAgent(ctx, llm, rt, req, nil)
|
||
}
|
||
|
||
// RunAgentStream 同 RunAgent,经 emit 边跑边推(步骤 / 正文块 / done)。
|
||
func RunAgentStream(ctx context.Context, llm ToolChat, rt *aitools.Runtime, req AgentReq, emit AgentEmitter) (AgentRes, error) {
|
||
return runAgent(ctx, llm, rt, req, emit)
|
||
}
|
||
|
||
func runAgent(ctx context.Context, llm ToolChat, rt *aitools.Runtime, req AgentReq, emit AgentEmitter) (AgentRes, error) {
|
||
if rt == nil {
|
||
return AgentRes{}, fmt.Errorf("aitools runtime required")
|
||
}
|
||
if llm == nil {
|
||
return AgentRes{}, fmt.Errorf("tool chat LLM required")
|
||
}
|
||
prompt := strings.TrimSpace(req.Prompt)
|
||
if prompt == "" {
|
||
return AgentRes{}, fmt.Errorf("prompt required")
|
||
}
|
||
maxRounds := req.MaxRounds
|
||
if maxRounds <= 0 {
|
||
maxRounds = defaultAgentMaxRounds
|
||
}
|
||
if maxRounds > 16 {
|
||
maxRounds = 16
|
||
}
|
||
|
||
// Agent 路径禁止模型自行带确认令牌
|
||
rt.RejectCallerConfirm = true
|
||
|
||
tools := aitools.OpenAITools()
|
||
msgs := []Message{
|
||
{Role: "system", Content: buildAgentSystem(req)},
|
||
{Role: "user", Content: prompt},
|
||
}
|
||
|
||
var steps []AgentStep
|
||
rounds := 0
|
||
for round := 0; round < maxRounds; round++ {
|
||
if err := ctx.Err(); err != nil {
|
||
res := AgentRes{Steps: steps, Rounds: rounds, Content: "已取消"}
|
||
emitEvent(emit, AgentEvent{Type: AgentEventError, Error: "cancelled", Steps: steps, Rounds: rounds})
|
||
return res, err
|
||
}
|
||
rounds = round + 1
|
||
turn, err := llm.ChatWithTools(ctx, req.Model, msgs, tools)
|
||
if err != nil {
|
||
emitEvent(emit, AgentEvent{Type: AgentEventError, Error: err.Error(), Steps: steps, Rounds: rounds})
|
||
return AgentRes{Steps: steps, Rounds: rounds}, err
|
||
}
|
||
if len(turn.ToolCalls) == 0 {
|
||
content := turn.Content
|
||
if content == "" {
|
||
content = "(模型未返回文本,也未调用工具)"
|
||
}
|
||
for _, part := range chunkDelta(content, 36) {
|
||
emitEvent(emit, AgentEvent{Type: AgentEventDelta, Delta: part})
|
||
}
|
||
res := AgentRes{
|
||
Content: content,
|
||
SQL: extractSQL(content),
|
||
Steps: steps,
|
||
Rounds: rounds,
|
||
}
|
||
emitEvent(emit, AgentEvent{Type: AgentEventDone, Content: res.Content, SQL: res.SQL, Steps: steps, Rounds: rounds})
|
||
return res, nil
|
||
}
|
||
|
||
msgs = append(msgs, Message{
|
||
Role: "assistant",
|
||
Content: turn.Content,
|
||
ToolCalls: turn.ToolCalls,
|
||
})
|
||
|
||
for _, tc := range turn.ToolCalls {
|
||
name := strings.TrimSpace(tc.Function.Name)
|
||
pending := AgentStep{Tool: name, Args: truncateRunes(strings.TrimSpace(tc.Function.Arguments), maxStepArgsChars), Pending: true}
|
||
emitEvent(emit, AgentEvent{Type: AgentEventStepStart, Step: &pending})
|
||
|
||
step, toolMsg := execOneTool(ctx, rt, tc)
|
||
steps = append(steps, step)
|
||
cp := step
|
||
ev := AgentEvent{Type: AgentEventStep, Step: &cp}
|
||
if step.Result != nil {
|
||
ev.Result = step.Result
|
||
}
|
||
emitEvent(emit, ev)
|
||
msgs = append(msgs, toolMsg)
|
||
}
|
||
}
|
||
|
||
content := fmt.Sprintf("已达最大工具轮次(%d)。请根据已执行步骤自行判断,或缩小问题后重试。", maxRounds)
|
||
for _, part := range chunkDelta(content, 36) {
|
||
emitEvent(emit, AgentEvent{Type: AgentEventDelta, Delta: part})
|
||
}
|
||
res := AgentRes{Content: content, Steps: steps, Rounds: rounds}
|
||
emitEvent(emit, AgentEvent{Type: AgentEventDone, Content: content, Steps: steps, Rounds: rounds})
|
||
return res, nil
|
||
}
|
||
|
||
func buildAgentSystem(req AgentReq) string {
|
||
var b strings.Builder
|
||
b.WriteString("你是 OpsxDB 数据库 Agent。你可以通过工具查询用户已白名单的连接。\n")
|
||
b.WriteString("规则:\n")
|
||
b.WriteString("1. 需要查库时必须调用工具;不要假装已执行 SQL。\n")
|
||
b.WriteString("2. 默认只读:execute_query 以 SELECT/SHOW/EXPLAIN/DESCRIBE/WITH…SELECT 为主;Redis 以读命令为主。\n")
|
||
b.WriteString("3. 写操作若被拒绝并提示需要确认,向用户说明须在界面确认后执行;不要声称已写入。\n")
|
||
b.WriteString("4. 先确认 connection_id(可用 opsxdb_list_connections);用户给出偏好连接时优先用它。\n")
|
||
b.WriteString("5. 用中文简明回答;可执行 SQL 放在 ```sql 代码块中。\n")
|
||
b.WriteString("6. 工具结果可能截断,注意说明。\n")
|
||
if req.ConnectionID != "" {
|
||
b.WriteString(fmt.Sprintf("\n用户当前偏好连接 id: %s", req.ConnectionID))
|
||
if req.Driver != "" {
|
||
b.WriteString(fmt.Sprintf("(%s)", req.Driver))
|
||
}
|
||
if req.Database != "" {
|
||
b.WriteString(fmt.Sprintf(",数据库: %s", req.Database))
|
||
}
|
||
b.WriteString("。\n")
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
func execOneTool(ctx context.Context, rt *aitools.Runtime, tc ToolCall) (AgentStep, Message) {
|
||
name := strings.TrimSpace(tc.Function.Name)
|
||
argsRaw := strings.TrimSpace(tc.Function.Arguments)
|
||
if argsRaw == "" {
|
||
argsRaw = "{}"
|
||
}
|
||
step := AgentStep{
|
||
Tool: name,
|
||
Args: truncateRunes(argsRaw, maxStepArgsChars),
|
||
}
|
||
start := time.Now()
|
||
|
||
var args map[string]any
|
||
if err := json.Unmarshal([]byte(argsRaw), &args); err != nil {
|
||
step.OK = false
|
||
step.Error = "invalid tool arguments JSON: " + err.Error()
|
||
step.Summary = step.Error
|
||
step.Ms = time.Since(start).Milliseconds()
|
||
return step, Message{
|
||
Role: "tool",
|
||
ToolCallID: tc.ID,
|
||
Name: name,
|
||
Content: step.Error,
|
||
}
|
||
}
|
||
// 永不让模型自行带确认令牌
|
||
delete(args, "confirm_danger")
|
||
delete(args, "confirmDanger")
|
||
|
||
out, err := rt.Call(ctx, name, args)
|
||
step.Ms = time.Since(start).Milliseconds()
|
||
if err != nil {
|
||
step.OK = false
|
||
step.Error = err.Error()
|
||
step.Summary = err.Error()
|
||
return step, Message{
|
||
Role: "tool",
|
||
ToolCallID: tc.ID,
|
||
Name: name,
|
||
Content: err.Error(),
|
||
}
|
||
}
|
||
|
||
if m, ok := out.(map[string]any); ok {
|
||
if nc, _ := m["needConfirm"].(bool); nc {
|
||
step.OK = false
|
||
step.NeedConfirm = true
|
||
if sql, _ := m["sql"].(string); sql != "" {
|
||
step.ConfirmSQL = sql
|
||
} else if cmd, _ := m["command"].(string); cmd != "" {
|
||
step.ConfirmSQL = cmd
|
||
}
|
||
msg, _ := m["message"].(string)
|
||
if msg == "" {
|
||
msg = "requires user confirmation (confirm_danger)"
|
||
}
|
||
step.Error = msg
|
||
step.Summary = msg
|
||
feed := aitools.ResultJSON(out)
|
||
return step, Message{
|
||
Role: "tool",
|
||
ToolCallID: tc.ID,
|
||
Name: name,
|
||
Content: feed,
|
||
}
|
||
}
|
||
if name == aitools.ToolExecuteQuery || name == aitools.ToolExecuteRedisCommand {
|
||
if qr := extractQueryResult(name, args, m); qr != nil {
|
||
step.Result = qr
|
||
}
|
||
}
|
||
}
|
||
|
||
step.OK = true
|
||
step.Summary = summarizeToolResult(name, out)
|
||
feed := aitools.ResultJSON(out)
|
||
if len(feed) > maxToolResultFeedChars {
|
||
feed = feed[:maxToolResultFeedChars] + "\n…(truncated)"
|
||
}
|
||
return step, Message{
|
||
Role: "tool",
|
||
ToolCallID: tc.ID,
|
||
Name: name,
|
||
Content: feed,
|
||
}
|
||
}
|
||
|
||
func extractQueryResult(tool string, args map[string]any, m map[string]any) *AgentQueryResult {
|
||
cols, _ := m["columns"].([]string)
|
||
if cols == nil {
|
||
if raw, ok := m["columns"].([]any); ok {
|
||
cols = make([]string, 0, len(raw))
|
||
for _, c := range raw {
|
||
cols = append(cols, fmt.Sprint(c))
|
||
}
|
||
}
|
||
}
|
||
var rows []map[string]interface{}
|
||
switch v := m["rows"].(type) {
|
||
case []map[string]interface{}:
|
||
rows = v
|
||
case []any:
|
||
for _, item := range v {
|
||
if row, ok := item.(map[string]interface{}); ok {
|
||
rows = append(rows, row)
|
||
} else if row, ok := item.(map[string]any); ok {
|
||
rows = append(rows, row)
|
||
}
|
||
}
|
||
}
|
||
if len(cols) == 0 && len(rows) == 0 {
|
||
return nil
|
||
}
|
||
if len(rows) > maxGridRows {
|
||
rows = rows[:maxGridRows]
|
||
}
|
||
rc := len(rows)
|
||
switch v := m["rowCount"].(type) {
|
||
case int:
|
||
rc = v
|
||
case int64:
|
||
rc = int(v)
|
||
case float64:
|
||
rc = int(v)
|
||
}
|
||
sqlText, _ := args["sql"].(string)
|
||
if sqlText == "" {
|
||
sqlText, _ = args["command"].(string)
|
||
}
|
||
return &AgentQueryResult{
|
||
Columns: cols,
|
||
Rows: rows,
|
||
RowCount: rc,
|
||
SQL: sqlText,
|
||
Tool: tool,
|
||
}
|
||
}
|
||
|
||
func summarizeToolResult(tool string, out any) string {
|
||
m, ok := out.(map[string]any)
|
||
if !ok {
|
||
return truncateRunes(fmt.Sprint(out), 120)
|
||
}
|
||
lenAny := func(key string) (int, bool) {
|
||
switch v := m[key].(type) {
|
||
case []any:
|
||
return len(v), true
|
||
case []string:
|
||
return len(v), true
|
||
default:
|
||
return 0, false
|
||
}
|
||
}
|
||
switch tool {
|
||
case aitools.ToolListConnections:
|
||
if n, ok := lenAny("connections"); ok {
|
||
return fmt.Sprintf("%d connections", n)
|
||
}
|
||
case aitools.ToolListDatabases:
|
||
if n, ok := lenAny("databases"); ok {
|
||
return fmt.Sprintf("%d databases", n)
|
||
}
|
||
case aitools.ToolListTables:
|
||
if n, ok := lenAny("tables"); ok {
|
||
return fmt.Sprintf("%d tables", n)
|
||
}
|
||
case aitools.ToolDescribeTable:
|
||
if t, _ := m["table"].(string); t != "" {
|
||
if n, ok := lenAny("columns"); ok {
|
||
return fmt.Sprintf("%s (%d cols)", t, n)
|
||
}
|
||
return t
|
||
}
|
||
case aitools.ToolGetSchemaContext:
|
||
if s, ok := m["schema"].(string); ok {
|
||
return fmt.Sprintf("schema %d chars", len(s))
|
||
}
|
||
case aitools.ToolExecuteQuery, aitools.ToolExecuteRedisCommand:
|
||
rc := 0
|
||
switch v := m["rowCount"].(type) {
|
||
case int:
|
||
rc = v
|
||
case int64:
|
||
rc = int(v)
|
||
case float64:
|
||
rc = int(v)
|
||
}
|
||
return fmt.Sprintf("rows=%d", rc)
|
||
}
|
||
return truncateRunes(aitools.ResultJSON(out), 120)
|
||
}
|
||
|
||
func truncateRunes(s string, n int) string {
|
||
s = strings.Join(strings.Fields(s), " ")
|
||
r := []rune(s)
|
||
if len(r) <= n {
|
||
return s
|
||
}
|
||
return string(r[:n]) + "…"
|
||
}
|