diff --git a/docs/15-AI-MCP-Agent.md b/docs/15-AI-MCP-Agent.md index 41e0691..5873550 100644 --- a/docs/15-AI-MCP-Agent.md +++ b/docs/15-AI-MCP-Agent.md @@ -212,6 +212,20 @@ go build -o ../bin/opsxdb-mcp ./cmd/opsxdb-mcp MySQL 3306 demo + Redis 6379:`go run ./cmd/opsxdb-mcp-accept/` 全过。实践文档:`~/Downloads/AI-Agent接入GUI工具开发实践.md`。 +## 会话产品层(阶段 5,2026-10-15) + +工具面(MCP / Ask|Agent / SSE / P1 确认)已齐;下一层是 **会话生命周期**。权威契约见 **[16-AI-Session-Harness.md](./16-AI-Session-Harness.md)**: + +| 项 | 说明 | +|----|------| +| RunOutcome | `completed` / `incomplete` / `failed` / `cancelled` / `awaiting_confirm` | +| 真·续跑 | 落盘 tool 轨迹 + 注入 prior;仅气泡回放不算 | +| Harness | MaxRounds、trim、循环拒、检查点(桌面裁剪,无 Evolver) | +| 排队 | 忙时单槽 pending(对齐 aiops 14) | +| 安全 | 脱敏落盘;写确认绑定 connection+语句哈希 | + +本轮代码:outcome / run_id 审计 / TrimMessages / Redact / ConfirmBind;会话 HTTP 与排队 UI 后挂。 + ## 阶段 3–4 完成记录(2026-10-15) | 项 | 状态 | diff --git a/docs/16-AI-Session-Harness.md b/docs/16-AI-Session-Harness.md new file mode 100644 index 0000000..a83550e --- /dev/null +++ b/docs/16-AI-Session-Harness.md @@ -0,0 +1,255 @@ +# 16 - AI 会话产品层:状态机 · incomplete · Harness · 排队 + +> 更新:2026-10-15 · 承接 `docs/15-AI-MCP-Agent.md`(工具面已齐) +> 借鉴:`/Users/logan/devops/aiops/docs/ai-agent` 的 03 / 05 / 06 / 13 / 14 +> 产品锁:MCP-first;默认只读;写需 `mode=read_write` + 确认;桌面单用户;**不做** Evolver / 审批流 / 跨产品共用执行器 + +本文定义 OpsxDB **会话产品层**契约:工具层(aitools / MCP)之上,侧栏 Agent 如何持久化、触顶、续跑、排队与安全确认。 + +--- + +## 1. 与 MCP / Agent 的关系 + +``` +外部 Cursor/Claude ──stdio──▶ opsxdb-mcp ──┐ + ▼ +侧栏 Ask(不调工具) aitools.Runtime(同源) +侧栏 Agent ──▶ ai.runAgent / Harness ──▶ Runtime.Call + │ │ + ▼ ▼ + 会话落盘(目标) mcp-audit.jsonl(含 run_id) +``` + +| 层 | 职责 | 不负责 | +|----|------|--------| +| **aitools.Runtime** | 白名单、只读/可写闸、危险 SQL、确认令牌、审计 | 多轮对话、预算、续跑 | +| **MCP stdio** | 把 Runtime 暴露给外部 Agent | 会话 UI、incomplete | +| **ai.RunAgent** | ReAct 循环、步骤事件、SSE | 连接增删、公网后端 | +| **会话产品层(本文)** | 落盘轨迹、RunOutcome、Harness 预算、排队、脱敏、确认绑定 | Evolver、审批流 | + +**真·续跑**(对齐 aiops 13):必须同时满足 (1) 持久化含 `tool_calls` + `tool` observation(含 `tool_call_id`);(2) 下次 Run 把历史注入引擎。仅恢复气泡不算 resume。 + +--- + +## 2. 会话状态机 + +### 2.1 Session(会话) + +| 状态 | 含义 | +|------|------| +| `idle` | 有历史或空会话,无进行中的 run | +| `running` | 正在执行一轮 Agent | +| `awaiting_confirm` | 写操作待用户确认(可与 running 同条气泡内嵌) | +| `incomplete` | 上一趟因预算/超时停住,轨迹可续 | +| `completed` | 上一趟正常结束 | +| `failed` | 不可恢复错误 | +| `cancelled` | 用户停止 | + +桌面单用户:会话默认落 `~/.opsxdb/ai-sessions/.json`(目标态;首版可仅内存 + 可选落盘)。 + +### 2.2 RunOutcome(单次运行结束,必填) + +对齐 aiops `13-运行结果状态与可续跑契约.md`: + +| outcome | 含义 | UI | +|---------|------|-----| +| `completed` | 预算内给出最终答案 | 正常 | +| `incomplete` | 未完成但有部分轨迹 | 「加预算继续」/「继续」 | +| `failed` | 协议/鉴权/致命工具错 | 失败 | +| `cancelled` | 用户 Abort | 已取消 | +| `awaiting_confirm` | 安全闸等待确认 | 确认条 | + +### 2.3 IncompleteReason + +| reason | 触发 | +|--------|------| +| `max_steps` | 触达 MaxRounds / MaxIterations | +| `context_budget` | trim 后无法安全继续 | +| `tool_timeout` | 单工具超时 | +| `llm_timeout` | LLM 超时 | +| `safety_abort` | 安全中止(非等待确认) | + +**规则**:触顶步数 → `incomplete` + `max_steps`,**不得**伪装成 `completed`(现状 `agent.go` 触顶仍发 `done`,见 §6 审查)。 + +### 2.4 对外字段(API / SSE 目标态) + +```text +run_id # 一次 Run 全链路 ID(审计 / 步骤共用) +session_id # 可选;有落盘时必填 +run_outcome # completed | incomplete | failed | cancelled | awaiting_confirm +incomplete_reason? # 见上表 +resumable # 是否满足真·续跑条件 +steps_used / steps_budget +partial_summary? # 触顶时进展摘要 +``` + +SSE:在现有 `step_start` / `step` / `delta` / `done` / `error` 上,目标增加显式 `incomplete` 事件(或 `done` 携带 `run_outcome=incomplete`)。首版代码已在 `AgentRes` / `AgentEvent` 挂上 outcome 字段。 + +--- + +## 3. Harness 预算与反馈 + +参考 aiops 03;OpsxDB 裁剪为桌面 DB Agent: + +| 旋钮 | 默认 | 说明 | +|------|------|------| +| `MaxRounds` | 8(上限 16) | ReAct 轮数预算,非质量承诺 | +| `MaxToolResultChars` | 12000 | 回灌 LLM 前截断 | +| `MaxContextTokens` | 8000(目标) | trimMessages 预算 | +| `ToolTimeout` | 与 db 层对齐(目标显式化) | 单工具 | +| `SameCallLimit` | 3(目标) | 同工具同参循环拒 | +| `Checkpoints` | 第 4 / 6 轮(目标) | 提示换思路 / 收尾 | + +### 3.1 trimMessages(目标行为) + +1. 永远保留 system +2. 从最近向前保留,约 `MaxContextTokens * 90%` +3. 至少保留最近 4 条 +4. 裁剪后按 `tool_call_id` 整组保留 assistant.tool_calls + tool 消息 + +代码:`ai.TrimMessages` 钩子已落地;Agent 主循环接入时机在「多轮续跑 / 长会话」打开时启用(单次短问可跳过)。 + +### 3.2 检查点与循环检测 + +目标:同参 ≥3 次拒绝并写入 tool observation;第 4/6 轮向 msgs 注入短 system/user 提醒。不做 Evolver。 + +--- + +## 4. 会话落盘与真·续跑 + +### 4.1 存储形状(目标) + +```json +{ + "id": "sess_…", + "title": "列出 demo 表", + "createdAt": "…", + "updatedAt": "…", + "status": "incomplete", + "messages": [ + {"role":"system","content":"…"}, + {"role":"user","content":"…"}, + {"role":"assistant","content":"…","toolCalls":[{"id":"c1","function":{…}}]}, + {"role":"tool","toolCallId":"c1","name":"opsxdb_list_tables","content":"…"} + ], + "runs": [{"runId":"run_…","outcome":"incomplete","reason":"max_steps","stepsUsed":8}] +} +``` + +脱敏后落盘(见 §5)。密码、连接串明文、大结果全文不进会话文件。 + +### 4.2 API 契约(目标,大 UI 可后做) + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/ai/sessions` | 列表 | +| GET | `/api/ai/sessions/:id` | 含 messages | +| POST | `/api/ai/agent` | 现有;增加可选 `sessionId` / `continue:true` / `prior` | +| POST | `/api/ai/sessions/:id/continue` | 注入 prior + 可选提高 MaxRounds | + +骨架:`server/internal/ai/session.go`(文件存储接口 + 类型;HTTP 路由可后挂)。 + +--- + +## 5. 脱敏与确认绑定 + +### 5.1 脱敏(落盘 / 审计 / 展示) + +对齐 aiops 05,桌面范围: + +| 类型 | 示例 | +|------|------| +| Bearer / API Key | `Bearer …`、`sk-…` | +| 密码 | `password=`、`pwd=` | +| 连接串 | `mysql://user:pass@host` | +| PEM 私钥块 | `BEGIN … PRIVATE KEY` | + +`ai.RedactSensitive` 已提供;会话落盘与审计 summary 应过脱敏。 + +### 5.2 确认绑定(写操作) + +写确认不得只靠 UI 气泡里的明文 SQL 再次提交而无绑定: + +```text +confirm_bind = sha256(connection_id + "\n" + normalize(sql_or_command)) +``` + +规则: + +1. `needConfirm` 响应带上 `confirmBind`(或服务端会话内缓存) +2. `/api/ai/agent/confirm` 校验:connection_id + 语句 与绑定一致,否则拒绝并要求重确认 +3. 用户改了 SQL → 哈希变 → 必须重新确认(防 TOCTOU) + +首版:`aitools.ConfirmBindHash` + Runtime needConfirm 返回字段;confirm 端点校验(见代码)。 + +--- + +## 6. 忙时单槽排队 + +对齐 aiops 14;桌面侧栏: + +| 动作 | 语义 | +|------|------| +| 排队 | running 时新输入进 `pending`(单槽;再提交覆盖) | +| 自动续发 | 当前 run 结束(任一 outcome)后若有 pending → 自动 continue | +| 立即 | 停止当前 → 立刻跑 pending | +| 编辑 / 撤销 | 拉回输入框 / 丢弃 | + +与 incomplete 续跑可叠加:排队是同会话并发输入;incomplete 是上一趟未完成再继续。 + +**首版**:文档 + 前端状态契约;服务端仍单请求单 run(Abort 用 ctx cancel)。不做跨会话队列、不做长 FIFO。 + +--- + +## 7. 细节审查:现状 → 缺口 → 建议 + +### 7.1 Agent loop(`server/internal/ai/agent.go`) + +| | | +|--|--| +| **现状** | ReAct:`ChatWithTools` → `execOneTool` → 回灌;默认 MaxRounds=8(帽 16);触顶文案后仍 `AgentEventDone`;无 session_id / prior;取消走 ctx → error 事件;NeedConfirm 回灌模型但不暂停 loop 为 `awaiting_confirm` outcome | +| **缺口** | 无结构化 `run_outcome`;触顶伪装 completed;无真·续跑;无同参循环拒;无中段检查点;确认不打断为等待态 | +| **建议** | 触顶 → `incomplete`/`max_steps` + `resumable`;`AgentRes` 带 outcome/run_id;可选 `Prior []Message`;NeedConfirm 可提前结束本 run 为 `awaiting_confirm`(UI 已有确认条,语义对齐即可) | + +### 7.2 Harness 架构 + +| | | +|--|--| +| **现状** | 预算散落常量(`defaultAgentMaxRounds`、`maxToolResultFeedChars`);无独立 Harness 配置对象;无 trim / 循环检测 / checkpoint;SSE 事件无 outcome | +| **缺口** | 与 aiops 03 的「工程 Harness」不对齐;上下文窗口未 own;长会话必炸或静默丢配对 | +| **建议** | `HarnessConfig` + `TrimMessages` + `DetectRepeatedToolCall`;Run 开头生成 `run_id` 写入 Runtime.Audit;检查点用轻量 user 消息注入,勿上 Evolver | + +### 7.3 MCP 同源 Runtime(`server/internal/aitools`) + +| | | +|--|--| +| **现状** | MCP 与 Agent 共用 `Runtime.Call`;Policy 白名单 + 只读默认;P1 `read_write` + needConfirm;审计 JSONL 有 source;无 run_id;确认端点不校验语句哈希绑定;Agent 剥离模型 confirm_danger | +| **缺口** | 审计无法按一次 Agent run 聚合;确认存在「改 SQL 仍用旧气泡点执行」窗口;MCP 外部客户端若带 confirm_danger 可在 read_write 下写(设计允许外部 Agent 自带确认语义,但桌面应对齐绑定) | +| **建议** | `AuditEntry.RunID`;`ConfirmBindHash`;confirm API 校验绑定;MCP 文档写明:外部客户端的 confirm 等同操作者授权,桌面 GUI 必须绑定哈希 | + +--- + +## 8. 落地节奏(本轮 / 下一刀) + +| 优先级 | 项 | 本轮 | +|--------|-----|------| +| P0 | 文档契约(本文)+ 15 交叉引用 | ✅ | +| P0 | RunOutcome 类型、触顶 incomplete、run_id 进审计 | ✅ 代码 | +| P0 | TrimMessages / Redact / ConfirmBind 钩子 | ✅ 代码 | +| P1 | 会话落盘 + continue API + UI 恢复 | 文档契约 + session 骨架 | +| P1 | 单槽排队 UI | 文档;前端后做 | +| P2 | 同参循环拒、检查点、Ask 真 token 流 | 后置 | + +--- + +## 9. 验收(会话层) + +- [ ] `go test ./internal/ai/ ./internal/aitools/` 通过(含 outcome / trim / redact / bind) +- [ ] 触顶轮次:响应 `run_outcome=incomplete`,`incomplete_reason=max_steps` +- [ ] 审计行含 `run_id`(Agent 路径) +- [ ] confirm:改 SQL 后旧 bind 拒绝 +- [ ] (P1)关窗再开:同 session 注入 prior 能继续调工具 + +--- + +*文档版本: v1.0 | 2026-10-15 CST | 先契约与小改,大 UI 后挂* diff --git a/server/internal/ai/agent.go b/server/internal/ai/agent.go index da9ce1e..dc45c90 100644 --- a/server/internal/ai/agent.go +++ b/server/internal/ai/agent.go @@ -19,13 +19,17 @@ const ( // 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"` + 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"` + SessionID string `json:"sessionId,omitempty"` + Continue bool `json:"continue,omitempty"` // 真·续跑:注入 Prior + Prior []Message `json:"prior,omitempty"` // 含 tool 轨迹的历史(不含本轮 user) + RunID string `json:"runId,omitempty"` // 可选;空则自动生成 } // AgentStep 挂在回复下的步骤时间线一项。 @@ -38,16 +42,23 @@ type AgentStep struct { Ms int64 `json:"ms"` NeedConfirm bool `json:"needConfirm,omitempty"` ConfirmSQL string `json:"confirmSql,omitempty"` + ConfirmBind string `json:"confirmBind,omitempty"` Pending bool `json:"pending,omitempty"` Result *AgentQueryResult `json:"result,omitempty"` } -// AgentRes Agent 最终回答 + 步骤。 +// AgentRes Agent 最终回答 + 步骤 + 运行结果契约。 type AgentRes struct { - Content string `json:"content"` - SQL string `json:"sql,omitempty"` - Steps []AgentStep `json:"steps"` - Rounds int `json:"rounds"` + Content string `json:"content"` + SQL string `json:"sql,omitempty"` + Steps []AgentStep `json:"steps"` + Rounds int `json:"rounds"` + RunID string `json:"runId,omitempty"` + SessionID string `json:"sessionId,omitempty"` + Outcome string `json:"runOutcome,omitempty"` + IncompleteReason string `json:"incompleteReason,omitempty"` + Resumable bool `json:"resumable,omitempty"` + StepsBudget int `json:"stepsBudget,omitempty"` } // RunAgent 多轮:LLM tool_call → aitools.Runtime.Call → 回灌。 @@ -69,39 +80,74 @@ func runAgent(ctx context.Context, llm ToolChat, rt *aitools.Runtime, req AgentR return AgentRes{}, fmt.Errorf("tool chat LLM required") } prompt := strings.TrimSpace(req.Prompt) - if prompt == "" { + if prompt == "" && !(req.Continue && len(req.Prior) > 0) { return AgentRes{}, fmt.Errorf("prompt required") } - maxRounds := req.MaxRounds - if maxRounds <= 0 { - maxRounds = defaultAgentMaxRounds - } - if maxRounds > 16 { - maxRounds = 16 - } + h := DefaultHarness() + maxRounds := h.ClampRounds(req.MaxRounds) + runID := strings.TrimSpace(req.RunID) + if runID == "" { + runID = NewRunID() + } + rt.RunID = runID // Agent 路径禁止模型自行带确认令牌 rt.RejectCallerConfirm = true tools := aitools.OpenAITools() - msgs := []Message{ - {Role: "system", Content: buildAgentSystem(req)}, - {Role: "user", Content: prompt}, + var msgs []Message + if req.Continue && len(req.Prior) > 0 { + msgs = append([]Message{}, req.Prior...) + hasSystem := false + for _, m := range msgs { + if m.Role == "system" { + hasSystem = true + break + } + } + if !hasSystem { + msgs = append([]Message{{Role: "system", Content: buildAgentSystem(req)}}, msgs...) + } + if prompt != "" { + msgs = append(msgs, Message{Role: "user", Content: prompt}) + } + msgs = TrimMessages(msgs, h) + } else { + msgs = []Message{ + {Role: "system", Content: buildAgentSystem(req)}, + {Role: "user", Content: prompt}, + } + } + + baseMeta := func(steps []AgentStep, rounds int) AgentRes { + return AgentRes{ + Steps: steps, + Rounds: rounds, + RunID: runID, + SessionID: req.SessionID, + StepsBudget: maxRounds, + } } var steps []AgentStep + var callHistory []string 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}) + res := baseMeta(steps, rounds) + res.Content = "已取消" + res.Outcome = OutcomeCancelled + res.Resumable = len(steps) > 0 + emitEvent(emit, AgentEvent{Type: AgentEventError, Error: "cancelled", Steps: steps, Rounds: rounds, RunID: runID, Outcome: OutcomeCancelled}) 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 + res := baseMeta(steps, rounds) + res.Outcome = OutcomeFailed + emitEvent(emit, AgentEvent{Type: AgentEventError, Error: err.Error(), Steps: steps, Rounds: rounds, RunID: runID, Outcome: OutcomeFailed}) + return res, err } if len(turn.ToolCalls) == 0 { content := turn.Content @@ -111,13 +157,14 @@ func runAgent(ctx context.Context, llm ToolChat, rt *aitools.Runtime, req AgentR 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}) + res := baseMeta(steps, rounds) + res.Content = content + res.SQL = extractSQL(content) + res.Outcome = OutcomeCompleted + emitEvent(emit, AgentEvent{ + Type: AgentEventDone, Content: res.Content, SQL: res.SQL, Steps: steps, Rounds: rounds, + RunID: runID, Outcome: OutcomeCompleted, StepsBudget: maxRounds, + }) return res, nil } @@ -127,29 +174,76 @@ func runAgent(ctx context.Context, llm ToolChat, rt *aitools.Runtime, req AgentR ToolCalls: turn.ToolCalls, }) + awaiting := false 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}) + argsRaw := strings.TrimSpace(tc.Function.Arguments) + fp := ToolCallFingerprint(name, argsRaw) + if DetectRepeatedToolCall(callHistory, fp, h.SameCallLimit) { + step := AgentStep{ + Tool: name, + Args: truncateRunes(argsRaw, maxStepArgsChars), + OK: false, + Summary: "repeated tool call rejected (same args)", + Error: "repeated tool call rejected (same args)", + } + steps = append(steps, step) + cp := step + emitEvent(emit, AgentEvent{Type: AgentEventStep, Step: &cp, RunID: runID}) + msgs = append(msgs, Message{ + Role: "tool", ToolCallID: tc.ID, Name: name, + Content: step.Error, + }) + callHistory = append(callHistory, fp) + continue + } + callHistory = append(callHistory, fp) + + pending := AgentStep{Tool: name, Args: truncateRunes(argsRaw, maxStepArgsChars), Pending: true} + emitEvent(emit, AgentEvent{Type: AgentEventStepStart, Step: &pending, RunID: runID}) step, toolMsg := execOneTool(ctx, rt, tc) steps = append(steps, step) cp := step - ev := AgentEvent{Type: AgentEventStep, Step: &cp} + ev := AgentEvent{Type: AgentEventStep, Step: &cp, RunID: runID} if step.Result != nil { ev.Result = step.Result } emitEvent(emit, ev) msgs = append(msgs, toolMsg) + if step.NeedConfirm { + awaiting = true + } } + if awaiting { + content := "需要确认写操作后才能继续。" + res := baseMeta(steps, rounds) + res.Content = content + res.Outcome = OutcomeAwaitingConfirm + res.Resumable = true + emitEvent(emit, AgentEvent{ + Type: AgentEventDone, Content: content, Steps: steps, Rounds: rounds, + RunID: runID, Outcome: OutcomeAwaitingConfirm, Resumable: true, StepsBudget: maxRounds, + }) + return res, nil + } + msgs = TrimMessages(msgs, h) } - content := fmt.Sprintf("已达最大工具轮次(%d)。请根据已执行步骤自行判断,或缩小问题后重试。", maxRounds) + 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}) + res := baseMeta(steps, rounds) + res.Content = content + res.Outcome = OutcomeIncomplete + res.IncompleteReason = ReasonMaxSteps + res.Resumable = true + emitEvent(emit, AgentEvent{ + Type: AgentEventIncomplete, Content: content, Steps: steps, Rounds: rounds, + RunID: runID, Outcome: OutcomeIncomplete, IncompleteReason: ReasonMaxSteps, + Resumable: true, StepsBudget: maxRounds, + }) return res, nil } @@ -228,6 +322,9 @@ func execOneTool(ctx context.Context, rt *aitools.Runtime, tc ToolCall) (AgentSt } else if cmd, _ := m["command"].(string); cmd != "" { step.ConfirmSQL = cmd } + if bind, _ := m["confirmBind"].(string); bind != "" { + step.ConfirmBind = bind + } msg, _ := m["message"].(string) if msg == "" { msg = "requires user confirmation (confirm_danger)" diff --git a/server/internal/ai/agent_stream.go b/server/internal/ai/agent_stream.go index 8c12ea7..a7e733b 100644 --- a/server/internal/ai/agent_stream.go +++ b/server/internal/ai/agent_stream.go @@ -12,7 +12,8 @@ const ( AgentEventStep = "step" AgentEventDelta = "delta" AgentEventDone = "done" - AgentEventError = "error" + AgentEventError = "error" + AgentEventIncomplete = "incomplete" ) // AgentQueryResult 可供「在结果区打开」的行列结果。 @@ -26,15 +27,20 @@ type AgentQueryResult struct { // AgentEvent 流式推送的一项。 type AgentEvent struct { - Type string `json:"type"` - Step *AgentStep `json:"step,omitempty"` - Delta string `json:"delta,omitempty"` - Content string `json:"content,omitempty"` - SQL string `json:"sql,omitempty"` - Steps []AgentStep `json:"steps,omitempty"` - Rounds int `json:"rounds,omitempty"` - Error string `json:"error,omitempty"` - Result *AgentQueryResult `json:"result,omitempty"` + Type string `json:"type"` + Step *AgentStep `json:"step,omitempty"` + Delta string `json:"delta,omitempty"` + Content string `json:"content,omitempty"` + SQL string `json:"sql,omitempty"` + Steps []AgentStep `json:"steps,omitempty"` + Rounds int `json:"rounds,omitempty"` + Error string `json:"error,omitempty"` + Result *AgentQueryResult `json:"result,omitempty"` + RunID string `json:"runId,omitempty"` + Outcome string `json:"runOutcome,omitempty"` + IncompleteReason string `json:"incompleteReason,omitempty"` + Resumable bool `json:"resumable,omitempty"` + StepsBudget int `json:"stepsBudget,omitempty"` } // AgentEmitter 可选;nil 时仅返回最终 AgentRes(非流式 fallback)。 diff --git a/server/internal/ai/harness.go b/server/internal/ai/harness.go new file mode 100644 index 0000000..0c329e5 --- /dev/null +++ b/server/internal/ai/harness.go @@ -0,0 +1,165 @@ +package ai + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "strings" +) + +// HarnessConfig Agent 运行时预算(桌面裁剪版;不做 Evolver)。 +type HarnessConfig struct { + MaxRounds int // 默认 8,硬帽 16 + MaxToolResultChars int // 回灌截断 + MaxContextTokens int // trim 预算(粗估字符/4) + SameCallLimit int // 同工具同参上限;0=关闭 + MinRetainMessages int // trim 至少保留条数(不含 system) +} + +// DefaultHarness 与历史 agent.go 常量对齐。 +func DefaultHarness() HarnessConfig { + return HarnessConfig{ + MaxRounds: defaultAgentMaxRounds, + MaxToolResultChars: maxToolResultFeedChars, + MaxContextTokens: 8000, + SameCallLimit: 3, + MinRetainMessages: 4, + } +} + +func (h HarnessConfig) ClampRounds(n int) int { + if n <= 0 { + n = h.MaxRounds + if n <= 0 { + n = defaultAgentMaxRounds + } + } + if n > 16 { + n = 16 + } + return n +} + +// NewRunID 生成短 run_id,贯穿审计与步骤。 +func NewRunID() string { + var b [8]byte + _, _ = rand.Read(b[:]) + return "run_" + hex.EncodeToString(b[:]) +} + +// EstimateTokens 粗估(字符/4);含 tool_calls JSON。 +func EstimateTokens(msgs []Message) int { + n := 0 + for _, m := range msgs { + n += len(m.Content) + if len(m.ToolCalls) > 0 { + if b, err := json.Marshal(m.ToolCalls); err == nil { + n += len(b) + } + } + n += len(m.Name) + len(m.ToolCallID) + 8 + } + if n < 0 { + return 0 + } + return n / 4 +} + +// TrimMessages 保留 system + 最近消息,控制在 MaxContextTokens*90%; +// 并按 tool_call_id 整组保留 assistant.tool_calls 与对应 tool。 +func TrimMessages(msgs []Message, h HarnessConfig) []Message { + if len(msgs) == 0 { + return msgs + } + budget := h.MaxContextTokens + if budget <= 0 { + budget = 8000 + } + limit := budget * 90 / 100 + minRetain := h.MinRetainMessages + if minRetain <= 0 { + minRetain = 4 + } + + var system []Message + var rest []Message + for _, m := range msgs { + if m.Role == "system" { + system = append(system, m) + } else { + rest = append(rest, m) + } + } + if EstimateTokens(msgs) <= limit { + return ensureToolPairing(msgs) + } + + // 从尾部向前取,直到接近预算 + var kept []Message + for i := len(rest) - 1; i >= 0; i-- { + candidate := append([]Message{}, rest[i:]...) + candidate = append(append([]Message{}, system...), candidate...) + if EstimateTokens(candidate) > limit && len(kept) >= minRetain { + break + } + kept = rest[i:] + if len(kept) >= minRetain && EstimateTokens(candidate) <= limit { + break + } + } + if len(kept) == 0 && len(rest) > 0 { + // 至少留最后一条 + kept = rest[len(rest)-1:] + } + out := append(append([]Message{}, system...), kept...) + return ensureToolPairing(out) +} + +// ensureToolPairing 丢弃没有对应 assistant tool_calls 的孤立 tool,以及裁切后半截的 tool_calls。 +func ensureToolPairing(msgs []Message) []Message { + toolIDs := map[string]bool{} + for _, m := range msgs { + if m.Role == "assistant" { + for _, tc := range m.ToolCalls { + if tc.ID != "" { + toolIDs[tc.ID] = true + } + } + } + } + out := make([]Message, 0, len(msgs)) + for _, m := range msgs { + if m.Role == "tool" { + if m.ToolCallID == "" || !toolIDs[m.ToolCallID] { + continue + } + } + if m.Role == "assistant" && len(m.ToolCalls) > 0 { + // 若后续缺少任一 tool 结果,整组 assistant 仍保留(由调用方补 incomplete); + // 这里只滤 tool 侧孤儿。 + } + out = append(out, m) + } + return out +} + +// ToolCallFingerprint 同参循环检测键。 +func ToolCallFingerprint(name, argsJSON string) string { + name = strings.TrimSpace(name) + argsJSON = strings.Join(strings.Fields(argsJSON), " ") + return name + "\n" + argsJSON +} + +// DetectRepeatedToolCall 若同一指纹已出现 limit 次(不含本次),返回 true。 +func DetectRepeatedToolCall(history []string, fp string, limit int) bool { + if limit <= 0 || fp == "" { + return false + } + n := 0 + for _, h := range history { + if h == fp { + n++ + } + } + return n >= limit +} diff --git a/server/internal/ai/harness_test.go b/server/internal/ai/harness_test.go new file mode 100644 index 0000000..db0b8a8 --- /dev/null +++ b/server/internal/ai/harness_test.go @@ -0,0 +1,155 @@ +package ai + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "opsxdb/server/internal/aitools" +) + +func TestTrimMessagesKeepsSystemAndDropsOrphanTool(t *testing.T) { + h := DefaultHarness() + h.MaxContextTokens = 40 + msgs := []Message{ + {Role: "system", Content: "sys"}, + {Role: "user", Content: strings.Repeat("u", 400)}, + {Role: "assistant", Content: "a", ToolCalls: []ToolCall{{ID: "c1", Type: "function", Function: ToolCallFunc{Name: "opsxdb_list_tables", Arguments: "{}"}}}}, + {Role: "tool", ToolCallID: "c1", Name: "opsxdb_list_tables", Content: `{"tables":["users"]}`}, + {Role: "user", Content: "continue please"}, + } + out := TrimMessages(msgs, h) + hasSys := false + for _, m := range out { + if m.Role == "system" { + hasSys = true + } + } + if !hasSys { + t.Fatal("system dropped") + } + orphan := TrimMessages([]Message{ + {Role: "system", Content: "s"}, + {Role: "tool", ToolCallID: "x", Content: "nope"}, + {Role: "user", Content: "hi"}, + }, h) + for _, m := range orphan { + if m.Role == "tool" { + t.Fatalf("expected orphan tool dropped: %+v", orphan) + } + } +} + +func TestDetectRepeatedToolCall(t *testing.T) { + fp := ToolCallFingerprint("opsxdb_list_tables", `{"connection_id":"c1"}`) + hist := []string{fp, fp, fp} + if !DetectRepeatedToolCall(hist, fp, 3) { + t.Fatal("want reject at 3") + } + if DetectRepeatedToolCall(hist[:2], fp, 3) { + t.Fatal("should allow under limit") + } +} + +func TestRedactSensitive(t *testing.T) { + out := RedactSensitive("Bearer tokensecret password=secret mysql://user:pass@host/db") + if strings.Contains(out, "tokensecret") || strings.Contains(out, "password=secret") || strings.Contains(out, ":pass@") { + t.Fatalf("not redacted: %s", out) + } + if !strings.Contains(RedactSensitive("sk-abcdefghijklmnop"), "[REDACTED]") { + t.Fatal("sk key") + } +} + +func TestRunAgentMaxRoundsIncomplete(t *testing.T) { + rt := aitools.NewRuntime(aitools.Policy{ + Mode: aitools.ModeReadOnly, + AllowedConnectionIDs: []string{"c1"}, + DefaultLimit: 100, + MaxLimit: 500, + }) + rt.LoadConns = func() ([]aitools.SavedConnection, error) { + return []aitools.SavedConnection{{ID: "c1", Name: "d", Driver: "mysql"}}, nil + } + sawRunID := false + rt.Audit = func(e aitools.AuditEntry) { + if e.RunID != "" { + sawRunID = true + } + } + var turns []ToolsTurn + for i := 0; i < 3; i++ { + turns = append(turns, ToolsTurn{ToolCalls: []ToolCall{{ + ID: "c", Type: "function", + Function: ToolCallFunc{Name: aitools.ToolListConnections, Arguments: "{}"}, + }}}) + } + llm := &scriptedLLM{turns: turns} + res, err := RunAgent(context.Background(), llm, rt, AgentReq{Prompt: "loop", MaxRounds: 2}) + if err != nil { + t.Fatal(err) + } + if res.Outcome != OutcomeIncomplete { + t.Fatalf("outcome=%s want incomplete", res.Outcome) + } + if res.IncompleteReason != ReasonMaxSteps { + t.Fatalf("reason=%s", res.IncompleteReason) + } + if !res.Resumable || res.RunID == "" { + t.Fatalf("resumable/runId: %+v", res) + } + if !sawRunID { + t.Fatal("expected audit run_id") + } +} + +func TestRunAgentCompletedHasOutcome(t *testing.T) { + rt := aitools.NewRuntime(aitools.Policy{Mode: aitools.ModeReadOnly, AllowedConnectionIDs: []string{"c1"}, DefaultLimit: 100, MaxLimit: 500}) + rt.Audit = func(aitools.AuditEntry) {} + llm := &scriptedLLM{turns: []ToolsTurn{{Content: "hello"}}} + res, err := RunAgent(context.Background(), llm, rt, AgentReq{Prompt: "hi"}) + if err != nil { + t.Fatal(err) + } + if res.Outcome != OutcomeCompleted { + t.Fatalf("outcome=%s", res.Outcome) + } + if res.RunID == "" || res.StepsBudget != defaultAgentMaxRounds { + t.Fatalf("%+v", res) + } +} + +func TestRunAgentRepeatCallRejected(t *testing.T) { + rt := aitools.NewRuntime(aitools.Policy{Mode: aitools.ModeReadOnly, AllowedConnectionIDs: []string{"c1"}, DefaultLimit: 100, MaxLimit: 500}) + rt.LoadConns = func() ([]aitools.SavedConnection, error) { + return []aitools.SavedConnection{{ID: "c1", Name: "d", Driver: "mysql"}}, nil + } + rt.Audit = func(aitools.AuditEntry) {} + args := `{"connection_id":"c1"}` + var turns []ToolsTurn + // 同一轮多个相同 tool_call,以及跨轮:SameCallLimit=3 指 history 已有 3 次则拒第 4 次 + // 每轮一个相同调用,第 4 轮应被拒但仍继续到最终答案 + for i := 0; i < 4; i++ { + turns = append(turns, ToolsTurn{ToolCalls: []ToolCall{{ + ID: "c" + string(rune('1'+i)), Type: "function", + Function: ToolCallFunc{Name: aitools.ToolListConnections, Arguments: args}, + }}}) + } + turns = append(turns, ToolsTurn{Content: "done"}) + llm := &scriptedLLM{turns: turns} + res, err := RunAgent(context.Background(), llm, rt, AgentReq{Prompt: "x", MaxRounds: 8}) + if err != nil { + t.Fatal(err) + } + rejected := 0 + for _, s := range res.Steps { + if strings.Contains(s.Summary, "repeated tool call") { + rejected++ + } + } + if rejected < 1 { + t.Fatalf("expected at least one repeated rejection, steps=%+v", res.Steps) + } + _ = json.Marshal +} diff --git a/server/internal/ai/outcome.go b/server/internal/ai/outcome.go new file mode 100644 index 0000000..fbc68cb --- /dev/null +++ b/server/internal/ai/outcome.go @@ -0,0 +1,19 @@ +package ai + +// RunOutcome 单次 Agent 运行结束状态(对齐 aiops docs/ai-agent/13)。 +const ( + OutcomeCompleted = "completed" + OutcomeIncomplete = "incomplete" + OutcomeFailed = "failed" + OutcomeCancelled = "cancelled" + OutcomeAwaitingConfirm = "awaiting_confirm" +) + +// IncompleteReason 仅当 Outcome==incomplete 时有意义。 +const ( + ReasonMaxSteps = "max_steps" + ReasonContextBudget = "context_budget" + ReasonToolTimeout = "tool_timeout" + ReasonLLMTimeout = "llm_timeout" + ReasonSafetyAbort = "safety_abort" +) diff --git a/server/internal/ai/redact.go b/server/internal/ai/redact.go new file mode 100644 index 0000000..3299695 --- /dev/null +++ b/server/internal/ai/redact.go @@ -0,0 +1,42 @@ +package ai + +import ( + "regexp" + "strings" +) + +var ( + reBearer = regexp.MustCompile(`(?i)(Bearer\s+)[A-Za-z0-9._\-]+`) + reSkKey = regexp.MustCompile(`(?i)\b(sk-[A-Za-z0-9]{8,})`) + rePassword = regexp.MustCompile(`(?i)(password|passwd|pwd)\s*[=:]\s*\S+`) + reDSN = regexp.MustCompile(`(?i)\b((?:mysql|postgres|postgresql|mongodb|redis)://[^:\s/]+):([^@\s]+)@`) + rePEM = regexp.MustCompile(`(?s)-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----.*?-----END [A-Z0-9 ]*PRIVATE KEY-----`) +) + +// RedactSensitive 脱敏:落盘 / 审计 summary / 展示共用。 +func RedactSensitive(s string) string { + if s == "" { + return s + } + s = rePEM.ReplaceAllString(s, "[REDACTED_PRIVATE_KEY]") + s = reDSN.ReplaceAllString(s, "${1}:[REDACTED]@") + s = reBearer.ReplaceAllString(s, "${1}[REDACTED]") + s = reSkKey.ReplaceAllString(s, "sk-[REDACTED]") + s = rePassword.ReplaceAllString(s, "${1}=[REDACTED]") + return s +} + +// RedactMessages 对消息 Content 做脱敏(不改 tool_call 结构)。 +func RedactMessages(msgs []Message) []Message { + out := make([]Message, len(msgs)) + copy(out, msgs) + for i := range out { + out[i].Content = RedactSensitive(out[i].Content) + } + return out +} + +// NormalizeStatement 确认绑定前规范化 SQL/命令。 +func NormalizeStatement(s string) string { + return strings.Join(strings.Fields(strings.TrimSpace(s)), " ") +} diff --git a/server/internal/ai/session.go b/server/internal/ai/session.go new file mode 100644 index 0000000..7ba1ed1 --- /dev/null +++ b/server/internal/ai/session.go @@ -0,0 +1,89 @@ +package ai + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" +) + +// Session 桌面本地会话(目标态落盘;HTTP 路由可后挂)。 +type Session struct { + ID string `json:"id"` + Title string `json:"title,omitempty"` + Status string `json:"status"` // idle|running|incomplete|completed|failed|cancelled + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + Messages []Message `json:"messages"` + Runs []SessionRun `json:"runs,omitempty"` +} + +// SessionRun 一次运行摘要。 +type SessionRun struct { + RunID string `json:"runId"` + Outcome string `json:"outcome"` + IncompleteReason string `json:"incompleteReason,omitempty"` + Resumable bool `json:"resumable"` + StepsUsed int `json:"stepsUsed"` + StepsBudget int `json:"stepsBudget"` + FinishedAt string `json:"finishedAt"` +} + +func sessionsDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".opsxdb", "ai-sessions"), nil +} + +// SessionPath 返回会话文件路径。 +func SessionPath(id string) (string, error) { + dir, err := sessionsDir() + if err != nil { + return "", err + } + return filepath.Join(dir, id+".json"), nil +} + +// SaveSession 脱敏后写入磁盘。 +func SaveSession(s Session) error { + if s.ID == "" { + return fmt.Errorf("session id required") + } + s.Messages = RedactMessages(s.Messages) + s.UpdatedAt = time.Now().Format(time.RFC3339) + if s.CreatedAt == "" { + s.CreatedAt = s.UpdatedAt + } + path, err := SessionPath(s.ID) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return err + } + b, err := json.MarshalIndent(s, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, b, 0600) +} + +// LoadSession 读会话;不存在返回 os.ErrNotExist。 +func LoadSession(id string) (Session, error) { + path, err := SessionPath(id) + if err != nil { + return Session{}, err + } + b, err := os.ReadFile(path) + if err != nil { + return Session{}, err + } + var s Session + if err := json.Unmarshal(b, &s); err != nil { + return Session{}, err + } + return s, nil +} diff --git a/server/internal/aitools/audit.go b/server/internal/aitools/audit.go index 1674599..d317776 100644 --- a/server/internal/aitools/audit.go +++ b/server/internal/aitools/audit.go @@ -15,6 +15,7 @@ type AuditEntry struct { ConnectionID string `json:"connection_id,omitempty"` Mode string `json:"mode"` Source string `json:"source,omitempty"` // mcp | agent + RunID string `json:"run_id,omitempty"` OK bool `json:"ok"` Summary string `json:"summary,omitempty"` Error string `json:"error,omitempty"` diff --git a/server/internal/aitools/confirm_bind.go b/server/internal/aitools/confirm_bind.go new file mode 100644 index 0000000..54105bd --- /dev/null +++ b/server/internal/aitools/confirm_bind.go @@ -0,0 +1,29 @@ +package aitools + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" +) + +// ConfirmBindHash 绑定 connection + 规范化语句;改语句则哈希变,须重确认。 +func ConfirmBindHash(connectionID, statement string) string { + connectionID = strings.TrimSpace(connectionID) + statement = strings.Join(strings.Fields(strings.TrimSpace(statement)), " ") + sum := sha256.Sum256([]byte(connectionID + "\n" + statement)) + return hex.EncodeToString(sum[:]) +} + +// VerifyConfirmBind 校验客户端带回的 bind;空 bind 视为不合法(桌面路径必须带)。 +func VerifyConfirmBind(connectionID, statement, bind string) error { + bind = strings.TrimSpace(bind) + if bind == "" { + return fmt.Errorf("confirmBind required") + } + want := ConfirmBindHash(connectionID, statement) + if !strings.EqualFold(bind, want) { + return fmt.Errorf("confirmBind mismatch: statement or connection changed; re-confirm required") + } + return nil +} diff --git a/server/internal/aitools/confirm_bind_test.go b/server/internal/aitools/confirm_bind_test.go new file mode 100644 index 0000000..3f83913 --- /dev/null +++ b/server/internal/aitools/confirm_bind_test.go @@ -0,0 +1,57 @@ +package aitools + +import "testing" + +func TestConfirmBindHashStableAndSensitive(t *testing.T) { + a := ConfirmBindHash("c1", "INSERT INTO t VALUES (1)") + b := ConfirmBindHash("c1", " INSERT INTO t VALUES (1) ") + if a != b { + t.Fatalf("normalize mismatch %s vs %s", a, b) + } + c := ConfirmBindHash("c1", "INSERT INTO t VALUES (2)") + if a == c { + t.Fatal("different sql should differ") + } + if err := VerifyConfirmBind("c1", "INSERT INTO t VALUES (1)", a); err != nil { + t.Fatal(err) + } + if err := VerifyConfirmBind("c1", "INSERT INTO t VALUES (2)", a); err == nil { + t.Fatal("want mismatch") + } + if err := VerifyConfirmBind("c1", "INSERT INTO t VALUES (1)", ""); err == nil { + t.Fatal("want empty bind error") + } +} + +func TestNeedConfirmIncludesBind(t *testing.T) { + rt := NewRuntime(Policy{ + Mode: ModeReadWrite, + AllowedConnectionIDs: []string{"c1"}, + DefaultLimit: 100, + MaxLimit: 500, + }) + rt.RejectCallerConfirm = true + rt.FindConn = func(id string) (*SavedConnection, error) { + return &SavedConnection{ID: id, Driver: "mysql", Host: "127.0.0.1"}, nil + } + rt.Audit = func(AuditEntry) {} + out, err := rt.Call(nil, ToolExecuteQuery, map[string]any{ + "connection_id": "c1", + "sql": "DELETE FROM users WHERE id=1", + }) + if err != nil { + t.Fatal(err) + } + m, ok := out.(map[string]any) + if !ok || m["needConfirm"] != true { + t.Fatalf("%v", out) + } + bind, _ := m["confirmBind"].(string) + if bind == "" { + t.Fatal("missing confirmBind") + } + want := ConfirmBindHash("c1", "DELETE FROM users WHERE id=1") + if bind != want { + t.Fatalf("bind %s want %s", bind, want) + } +} diff --git a/server/internal/aitools/runtime.go b/server/internal/aitools/runtime.go index 1086fe6..a19c6b7 100644 --- a/server/internal/aitools/runtime.go +++ b/server/internal/aitools/runtime.go @@ -53,6 +53,8 @@ type Runtime struct { AllowConfirmedWrite bool // Source 写入审计:mcp | agent。 Source string + // RunID 写入审计,串联一次 Agent run。 + RunID string } func NewRuntime(policy Policy) *Runtime { @@ -77,6 +79,9 @@ func (r *Runtime) audit(e AuditEntry) { if e.Source == "" { e.Source = r.Source } + if e.RunID == "" && r.RunID != "" { + e.RunID = r.RunID + } if r.Audit != nil { r.Audit(e) return @@ -399,11 +404,13 @@ func (r *Runtime) executeQuery(ctx context.Context, args map[string]any) (any, e // P1:可写已开启,但无确认令牌则返回 needConfirm(永不静默写)。 if !confirmed { r.audit(AuditEntry{Tool: ToolExecuteQuery, ConnectionID: id, OK: false, Error: "needConfirm", Summary: truncate(sqlText, 80)}) + bind := ConfirmBindHash(id, sqlText) return map[string]any{ - "needConfirm": true, - "sql": sqlText, - "message": "write requires explicit user confirmation", - "reason": readReason, + "needConfirm": true, + "sql": sqlText, + "confirmBind": bind, + "message": "write requires explicit user confirmation", + "reason": readReason, }, nil } } @@ -471,11 +478,13 @@ func (r *Runtime) executeRedis(ctx context.Context, args map[string]any) (any, e } if !confirmed { r.audit(AuditEntry{Tool: ToolExecuteRedisCommand, ConnectionID: id, OK: false, Error: "needConfirm", Summary: truncate(command, 80)}) + bind := ConfirmBindHash(id, command) return map[string]any{ - "needConfirm": true, - "command": command, - "message": "redis write requires explicit user confirmation", - "reason": rerr.Error(), + "needConfirm": true, + "command": command, + "confirmBind": bind, + "message": "redis write requires explicit user confirmation", + "reason": rerr.Error(), }, nil } } diff --git a/server/internal/server/router.go b/server/internal/server/router.go index 4524ef9..fb66c70 100644 --- a/server/internal/server/router.go +++ b/server/internal/server/router.go @@ -532,6 +532,8 @@ func NewRouter(token string) *gin.Engine { Command string `json:"command"` Database string `json:"database"` Kind string `json:"kind"` // query | redis + ConfirmBind string `json:"confirmBind"` + RunID string `json:"runId"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -546,7 +548,16 @@ func NewRouter(token string) *gin.Engine { c.JSON(http.StatusForbidden, gin.H{"error": "writes disabled: set mode=read_write in ~/.opsxdb/mcp.json"}) return } + stmt := strings.TrimSpace(req.SQL) + if stmt == "" { + stmt = strings.TrimSpace(req.Command) + } + if err := aitools.VerifyConfirmBind(req.ConnectionID, stmt, req.ConfirmBind); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } rt.Source = "agent" + rt.RunID = strings.TrimSpace(req.RunID) rt.RejectCallerConfirm = false rt.AllowConfirmedWrite = true kind := strings.ToLower(strings.TrimSpace(req.Kind)) diff --git a/src/AiPanel.tsx b/src/AiPanel.tsx index 0e23795..8f66fc7 100644 --- a/src/AiPanel.tsx +++ b/src/AiPanel.tsx @@ -43,9 +43,17 @@ function splitContent(content: string): ContentSeg[] { /** R2-F7:等宽代码块 + 顶部「复制」「插入编辑器」小按钮 */ function CodeBlock({ code }: { code: string }) { const notify = useWorkspaceStore((s) => s.notify); + const notifyError = useWorkspaceStore((s) => s.notifyError); + const [copied, setCopied] = useState(false); + const copiedTimer = useRef | null>(null); + useEffect(() => () => { if (copiedTimer.current) clearTimeout(copiedTimer.current); }, []); const copy = async () => { - await navigator.clipboard.writeText(code); - notify({ kind: "success", title: "已复制", message: "SQL 已复制", mode: "toast", durationMs: 1200 }); + try { + await navigator.clipboard.writeText(code); + setCopied(true); + if (copiedTimer.current) clearTimeout(copiedTimer.current); + copiedTimer.current = setTimeout(() => setCopied(false), 1200); + } catch (error) { notifyError(error, "toast"); } }; const insertToEditor = () => { const st = useWorkspaceStore.getState(); @@ -61,7 +69,7 @@ function CodeBlock({ code }: { code: string }) { style={{ borderColor: "var(--ops-border-soft)" }} >