feat(ai): Agent SSE + Anthropic/responses tools + P1 确认写

- 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
This commit is contained in:
logan committed 2026-10-15 13:00:40 +08:00
1 parent 1d6e89449a
commit 2336c9433c
17 files changed
+1324 -197

No files matched your search

+150 -24
View File
@@ -11,9 +11,10 @@ import (
)
const (
defaultAgentMaxRounds = 8
maxToolResultFeedChars = 12000
maxStepArgsChars = 200
defaultAgentMaxRounds = 8
maxToolResultFeedChars = 12000
maxStepArgsChars = 200
maxGridRows = 200
)
// AgentReq 侧栏 Agent 请求(与 Ask 的 /api/ai/chat 分路径)。
@@ -24,16 +25,21 @@ type AgentReq struct {
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"`
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 最终回答 + 步骤。
@@ -44,9 +50,18 @@ type AgentRes struct {
Rounds int `json:"rounds"`
}
// RunAgent 多轮:LLM tool_call → aitools.Runtime.Call → 回灌,直到最终文本或轮次耗尽。
// ConfirmDanger 永不自动 true(由 Runtime 保证)。Ask 模式不要走这里。
// 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")
}
@@ -65,6 +80,9 @@ func RunAgent(ctx context.Context, llm ToolChat, rt *aitools.Runtime, req AgentR
maxRounds = 16
}
// Agent 路径禁止模型自行带确认令牌
rt.RejectCallerConfirm = true
tools := aitools.OpenAITools()
msgs := []Message{
{Role: "system", Content: buildAgentSystem(req)},
@@ -74,9 +92,15 @@ func RunAgent(ctx context.Context, llm ToolChat, rt *aitools.Runtime, req AgentR
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 {
@@ -84,15 +108,19 @@ func RunAgent(ctx context.Context, llm ToolChat, rt *aitools.Runtime, req AgentR
if content == "" {
content = "(模型未返回文本,也未调用工具)"
}
return AgentRes{
for _, part := range chunkDelta(content, 36) {
emitEvent(emit, AgentEvent{Type: AgentEventDelta, Delta: part})
}
res := AgentRes{
Content: content,
SQL: extractSQL(content),
Steps: steps,
Rounds: rounds,
}, nil
}
emitEvent(emit, AgentEvent{Type: AgentEventDone, Content: res.Content, SQL: res.SQL, Steps: steps, Rounds: rounds})
return res, nil
}
// assistant 消息带 tool_calls
msgs = append(msgs, Message{
Role: "assistant",
Content: turn.Content,
@@ -100,28 +128,41 @@ func RunAgent(ctx context.Context, llm ToolChat, rt *aitools.Runtime, req AgentR
})
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)
}
}
return AgentRes{
Content: fmt.Sprintf("已达最大工具轮次(%d)。请根据已执行步骤自行判断,或缩小问题后重试。", maxRounds),
Steps: steps,
Rounds: rounds,
}, nil
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("你是 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. 先确认 connection_id(可用 opsxdb_list_connections);用户给出偏好连接时优先用它。\n")
b.WriteString("4. 用中文简明回答;可执行 SQL 放在 ```sql 代码块中。\n")
b.WriteString("5. 工具结果可能截断,注意说明。\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 != "" {
@@ -160,6 +201,9 @@ func execOneTool(ctx context.Context, rt *aitools.Runtime, tc ToolCall) (AgentSt
Content: step.Error,
}
}
// 永不让模型自行带确认令牌
delete(args, "confirm_danger")
delete(args, "confirmDanger")
out, err := rt.Call(ctx, name, args)
step.Ms = time.Since(start).Milliseconds()
@@ -174,6 +218,37 @@ func execOneTool(ctx context.Context, rt *aitools.Runtime, tc ToolCall) (AgentSt
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)
@@ -188,6 +263,57 @@ func execOneTool(ctx context.Context, rt *aitools.Runtime, tc ToolCall) (AgentSt
}
}
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 {