feat(ai): session harness contract — incomplete, run_id, confirm bind
Add docs/16 for session state machine, incomplete/resume, queue semantics, and agent-loop/harness/MCP gap review. Wire RunOutcome on max-rounds, run_id through audit, TrimMessages/Redact hooks, ConfirmBindHash on write confirm path; session file skeleton without HTTP yet. No push.
This commit is contained in:
1 parent
0aeee2b692
commit
5416e86379
17 files changed
+1041
-61
No files matched your search
@@ -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`。
|
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)
|
## 阶段 3–4 完成记录(2026-10-15)
|
||||||
|
|
||||||
| 项 | 状态 |
|
| 项 | 状态 |
|
||||||
|
|||||||
@@ -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/<session_id>.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 后挂*
|
||||||
+137
-40
@@ -19,13 +19,17 @@ const (
|
|||||||
|
|
||||||
// AgentReq 侧栏 Agent 请求(与 Ask 的 /api/ai/chat 分路径)。
|
// AgentReq 侧栏 Agent 请求(与 Ask 的 /api/ai/chat 分路径)。
|
||||||
type AgentReq struct {
|
type AgentReq struct {
|
||||||
Prompt string `json:"prompt"`
|
Prompt string `json:"prompt"`
|
||||||
ConnectionID string `json:"connectionId,omitempty"`
|
ConnectionID string `json:"connectionId,omitempty"`
|
||||||
Driver string `json:"driver,omitempty"`
|
Driver string `json:"driver,omitempty"`
|
||||||
Database string `json:"database,omitempty"`
|
Database string `json:"database,omitempty"`
|
||||||
Model string `json:"model,omitempty"`
|
Model string `json:"model,omitempty"`
|
||||||
MaxRounds int `json:"maxRounds,omitempty"`
|
MaxRounds int `json:"maxRounds,omitempty"`
|
||||||
Stream bool `json:"stream,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 挂在回复下的步骤时间线一项。
|
// AgentStep 挂在回复下的步骤时间线一项。
|
||||||
@@ -38,16 +42,23 @@ type AgentStep struct {
|
|||||||
Ms int64 `json:"ms"`
|
Ms int64 `json:"ms"`
|
||||||
NeedConfirm bool `json:"needConfirm,omitempty"`
|
NeedConfirm bool `json:"needConfirm,omitempty"`
|
||||||
ConfirmSQL string `json:"confirmSql,omitempty"`
|
ConfirmSQL string `json:"confirmSql,omitempty"`
|
||||||
|
ConfirmBind string `json:"confirmBind,omitempty"`
|
||||||
Pending bool `json:"pending,omitempty"`
|
Pending bool `json:"pending,omitempty"`
|
||||||
Result *AgentQueryResult `json:"result,omitempty"`
|
Result *AgentQueryResult `json:"result,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AgentRes Agent 最终回答 + 步骤。
|
// AgentRes Agent 最终回答 + 步骤 + 运行结果契约。
|
||||||
type AgentRes struct {
|
type AgentRes struct {
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
SQL string `json:"sql,omitempty"`
|
SQL string `json:"sql,omitempty"`
|
||||||
Steps []AgentStep `json:"steps"`
|
Steps []AgentStep `json:"steps"`
|
||||||
Rounds int `json:"rounds"`
|
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 → 回灌。
|
// 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")
|
return AgentRes{}, fmt.Errorf("tool chat LLM required")
|
||||||
}
|
}
|
||||||
prompt := strings.TrimSpace(req.Prompt)
|
prompt := strings.TrimSpace(req.Prompt)
|
||||||
if prompt == "" {
|
if prompt == "" && !(req.Continue && len(req.Prior) > 0) {
|
||||||
return AgentRes{}, fmt.Errorf("prompt required")
|
return AgentRes{}, fmt.Errorf("prompt required")
|
||||||
}
|
}
|
||||||
maxRounds := req.MaxRounds
|
h := DefaultHarness()
|
||||||
if maxRounds <= 0 {
|
maxRounds := h.ClampRounds(req.MaxRounds)
|
||||||
maxRounds = defaultAgentMaxRounds
|
|
||||||
}
|
|
||||||
if maxRounds > 16 {
|
|
||||||
maxRounds = 16
|
|
||||||
}
|
|
||||||
|
|
||||||
|
runID := strings.TrimSpace(req.RunID)
|
||||||
|
if runID == "" {
|
||||||
|
runID = NewRunID()
|
||||||
|
}
|
||||||
|
rt.RunID = runID
|
||||||
// Agent 路径禁止模型自行带确认令牌
|
// Agent 路径禁止模型自行带确认令牌
|
||||||
rt.RejectCallerConfirm = true
|
rt.RejectCallerConfirm = true
|
||||||
|
|
||||||
tools := aitools.OpenAITools()
|
tools := aitools.OpenAITools()
|
||||||
msgs := []Message{
|
var msgs []Message
|
||||||
{Role: "system", Content: buildAgentSystem(req)},
|
if req.Continue && len(req.Prior) > 0 {
|
||||||
{Role: "user", Content: prompt},
|
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 steps []AgentStep
|
||||||
|
var callHistory []string
|
||||||
rounds := 0
|
rounds := 0
|
||||||
for round := 0; round < maxRounds; round++ {
|
for round := 0; round < maxRounds; round++ {
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
res := AgentRes{Steps: steps, Rounds: rounds, Content: "已取消"}
|
res := baseMeta(steps, rounds)
|
||||||
emitEvent(emit, AgentEvent{Type: AgentEventError, Error: "cancelled", Steps: steps, Rounds: 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
|
return res, err
|
||||||
}
|
}
|
||||||
rounds = round + 1
|
rounds = round + 1
|
||||||
turn, err := llm.ChatWithTools(ctx, req.Model, msgs, tools)
|
turn, err := llm.ChatWithTools(ctx, req.Model, msgs, tools)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
emitEvent(emit, AgentEvent{Type: AgentEventError, Error: err.Error(), Steps: steps, Rounds: rounds})
|
res := baseMeta(steps, rounds)
|
||||||
return AgentRes{Steps: steps, Rounds: rounds}, err
|
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 {
|
if len(turn.ToolCalls) == 0 {
|
||||||
content := turn.Content
|
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) {
|
for _, part := range chunkDelta(content, 36) {
|
||||||
emitEvent(emit, AgentEvent{Type: AgentEventDelta, Delta: part})
|
emitEvent(emit, AgentEvent{Type: AgentEventDelta, Delta: part})
|
||||||
}
|
}
|
||||||
res := AgentRes{
|
res := baseMeta(steps, rounds)
|
||||||
Content: content,
|
res.Content = content
|
||||||
SQL: extractSQL(content),
|
res.SQL = extractSQL(content)
|
||||||
Steps: steps,
|
res.Outcome = OutcomeCompleted
|
||||||
Rounds: rounds,
|
emitEvent(emit, AgentEvent{
|
||||||
}
|
Type: AgentEventDone, Content: res.Content, SQL: res.SQL, Steps: steps, Rounds: rounds,
|
||||||
emitEvent(emit, AgentEvent{Type: AgentEventDone, Content: res.Content, SQL: res.SQL, Steps: steps, Rounds: rounds})
|
RunID: runID, Outcome: OutcomeCompleted, StepsBudget: maxRounds,
|
||||||
|
})
|
||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,29 +174,76 @@ func runAgent(ctx context.Context, llm ToolChat, rt *aitools.Runtime, req AgentR
|
|||||||
ToolCalls: turn.ToolCalls,
|
ToolCalls: turn.ToolCalls,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
awaiting := false
|
||||||
for _, tc := range turn.ToolCalls {
|
for _, tc := range turn.ToolCalls {
|
||||||
name := strings.TrimSpace(tc.Function.Name)
|
name := strings.TrimSpace(tc.Function.Name)
|
||||||
pending := AgentStep{Tool: name, Args: truncateRunes(strings.TrimSpace(tc.Function.Arguments), maxStepArgsChars), Pending: true}
|
argsRaw := strings.TrimSpace(tc.Function.Arguments)
|
||||||
emitEvent(emit, AgentEvent{Type: AgentEventStepStart, Step: &pending})
|
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)
|
step, toolMsg := execOneTool(ctx, rt, tc)
|
||||||
steps = append(steps, step)
|
steps = append(steps, step)
|
||||||
cp := step
|
cp := step
|
||||||
ev := AgentEvent{Type: AgentEventStep, Step: &cp}
|
ev := AgentEvent{Type: AgentEventStep, Step: &cp, RunID: runID}
|
||||||
if step.Result != nil {
|
if step.Result != nil {
|
||||||
ev.Result = step.Result
|
ev.Result = step.Result
|
||||||
}
|
}
|
||||||
emitEvent(emit, ev)
|
emitEvent(emit, ev)
|
||||||
msgs = append(msgs, toolMsg)
|
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) {
|
for _, part := range chunkDelta(content, 36) {
|
||||||
emitEvent(emit, AgentEvent{Type: AgentEventDelta, Delta: part})
|
emitEvent(emit, AgentEvent{Type: AgentEventDelta, Delta: part})
|
||||||
}
|
}
|
||||||
res := AgentRes{Content: content, Steps: steps, Rounds: rounds}
|
res := baseMeta(steps, rounds)
|
||||||
emitEvent(emit, AgentEvent{Type: AgentEventDone, Content: content, Steps: steps, Rounds: 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
|
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 != "" {
|
} else if cmd, _ := m["command"].(string); cmd != "" {
|
||||||
step.ConfirmSQL = cmd
|
step.ConfirmSQL = cmd
|
||||||
}
|
}
|
||||||
|
if bind, _ := m["confirmBind"].(string); bind != "" {
|
||||||
|
step.ConfirmBind = bind
|
||||||
|
}
|
||||||
msg, _ := m["message"].(string)
|
msg, _ := m["message"].(string)
|
||||||
if msg == "" {
|
if msg == "" {
|
||||||
msg = "requires user confirmation (confirm_danger)"
|
msg = "requires user confirmation (confirm_danger)"
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ const (
|
|||||||
AgentEventStep = "step"
|
AgentEventStep = "step"
|
||||||
AgentEventDelta = "delta"
|
AgentEventDelta = "delta"
|
||||||
AgentEventDone = "done"
|
AgentEventDone = "done"
|
||||||
AgentEventError = "error"
|
AgentEventError = "error"
|
||||||
|
AgentEventIncomplete = "incomplete"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AgentQueryResult 可供「在结果区打开」的行列结果。
|
// AgentQueryResult 可供「在结果区打开」的行列结果。
|
||||||
@@ -26,15 +27,20 @@ type AgentQueryResult struct {
|
|||||||
|
|
||||||
// AgentEvent 流式推送的一项。
|
// AgentEvent 流式推送的一项。
|
||||||
type AgentEvent struct {
|
type AgentEvent struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Step *AgentStep `json:"step,omitempty"`
|
Step *AgentStep `json:"step,omitempty"`
|
||||||
Delta string `json:"delta,omitempty"`
|
Delta string `json:"delta,omitempty"`
|
||||||
Content string `json:"content,omitempty"`
|
Content string `json:"content,omitempty"`
|
||||||
SQL string `json:"sql,omitempty"`
|
SQL string `json:"sql,omitempty"`
|
||||||
Steps []AgentStep `json:"steps,omitempty"`
|
Steps []AgentStep `json:"steps,omitempty"`
|
||||||
Rounds int `json:"rounds,omitempty"`
|
Rounds int `json:"rounds,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
Result *AgentQueryResult `json:"result,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)。
|
// AgentEmitter 可选;nil 时仅返回最终 AgentRes(非流式 fallback)。
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
@@ -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)), " ")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ type AuditEntry struct {
|
|||||||
ConnectionID string `json:"connection_id,omitempty"`
|
ConnectionID string `json:"connection_id,omitempty"`
|
||||||
Mode string `json:"mode"`
|
Mode string `json:"mode"`
|
||||||
Source string `json:"source,omitempty"` // mcp | agent
|
Source string `json:"source,omitempty"` // mcp | agent
|
||||||
|
RunID string `json:"run_id,omitempty"`
|
||||||
OK bool `json:"ok"`
|
OK bool `json:"ok"`
|
||||||
Summary string `json:"summary,omitempty"`
|
Summary string `json:"summary,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,6 +53,8 @@ type Runtime struct {
|
|||||||
AllowConfirmedWrite bool
|
AllowConfirmedWrite bool
|
||||||
// Source 写入审计:mcp | agent。
|
// Source 写入审计:mcp | agent。
|
||||||
Source string
|
Source string
|
||||||
|
// RunID 写入审计,串联一次 Agent run。
|
||||||
|
RunID string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRuntime(policy Policy) *Runtime {
|
func NewRuntime(policy Policy) *Runtime {
|
||||||
@@ -77,6 +79,9 @@ func (r *Runtime) audit(e AuditEntry) {
|
|||||||
if e.Source == "" {
|
if e.Source == "" {
|
||||||
e.Source = r.Source
|
e.Source = r.Source
|
||||||
}
|
}
|
||||||
|
if e.RunID == "" && r.RunID != "" {
|
||||||
|
e.RunID = r.RunID
|
||||||
|
}
|
||||||
if r.Audit != nil {
|
if r.Audit != nil {
|
||||||
r.Audit(e)
|
r.Audit(e)
|
||||||
return
|
return
|
||||||
@@ -399,11 +404,13 @@ func (r *Runtime) executeQuery(ctx context.Context, args map[string]any) (any, e
|
|||||||
// P1:可写已开启,但无确认令牌则返回 needConfirm(永不静默写)。
|
// P1:可写已开启,但无确认令牌则返回 needConfirm(永不静默写)。
|
||||||
if !confirmed {
|
if !confirmed {
|
||||||
r.audit(AuditEntry{Tool: ToolExecuteQuery, ConnectionID: id, OK: false, Error: "needConfirm", Summary: truncate(sqlText, 80)})
|
r.audit(AuditEntry{Tool: ToolExecuteQuery, ConnectionID: id, OK: false, Error: "needConfirm", Summary: truncate(sqlText, 80)})
|
||||||
|
bind := ConfirmBindHash(id, sqlText)
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"needConfirm": true,
|
"needConfirm": true,
|
||||||
"sql": sqlText,
|
"sql": sqlText,
|
||||||
"message": "write requires explicit user confirmation",
|
"confirmBind": bind,
|
||||||
"reason": readReason,
|
"message": "write requires explicit user confirmation",
|
||||||
|
"reason": readReason,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -471,11 +478,13 @@ func (r *Runtime) executeRedis(ctx context.Context, args map[string]any) (any, e
|
|||||||
}
|
}
|
||||||
if !confirmed {
|
if !confirmed {
|
||||||
r.audit(AuditEntry{Tool: ToolExecuteRedisCommand, ConnectionID: id, OK: false, Error: "needConfirm", Summary: truncate(command, 80)})
|
r.audit(AuditEntry{Tool: ToolExecuteRedisCommand, ConnectionID: id, OK: false, Error: "needConfirm", Summary: truncate(command, 80)})
|
||||||
|
bind := ConfirmBindHash(id, command)
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"needConfirm": true,
|
"needConfirm": true,
|
||||||
"command": command,
|
"command": command,
|
||||||
"message": "redis write requires explicit user confirmation",
|
"confirmBind": bind,
|
||||||
"reason": rerr.Error(),
|
"message": "redis write requires explicit user confirmation",
|
||||||
|
"reason": rerr.Error(),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -532,6 +532,8 @@ func NewRouter(token string) *gin.Engine {
|
|||||||
Command string `json:"command"`
|
Command string `json:"command"`
|
||||||
Database string `json:"database"`
|
Database string `json:"database"`
|
||||||
Kind string `json:"kind"` // query | redis
|
Kind string `json:"kind"` // query | redis
|
||||||
|
ConfirmBind string `json:"confirmBind"`
|
||||||
|
RunID string `json:"runId"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
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"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "writes disabled: set mode=read_write in ~/.opsxdb/mcp.json"})
|
||||||
return
|
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.Source = "agent"
|
||||||
|
rt.RunID = strings.TrimSpace(req.RunID)
|
||||||
rt.RejectCallerConfirm = false
|
rt.RejectCallerConfirm = false
|
||||||
rt.AllowConfirmedWrite = true
|
rt.AllowConfirmedWrite = true
|
||||||
kind := strings.ToLower(strings.TrimSpace(req.Kind))
|
kind := strings.ToLower(strings.TrimSpace(req.Kind))
|
||||||
|
|||||||
+20
-3
@@ -43,9 +43,17 @@ function splitContent(content: string): ContentSeg[] {
|
|||||||
/** R2-F7:等宽代码块 + 顶部「复制」「插入编辑器」小按钮 */
|
/** R2-F7:等宽代码块 + 顶部「复制」「插入编辑器」小按钮 */
|
||||||
function CodeBlock({ code }: { code: string }) {
|
function CodeBlock({ code }: { code: string }) {
|
||||||
const notify = useWorkspaceStore((s) => s.notify);
|
const notify = useWorkspaceStore((s) => s.notify);
|
||||||
|
const notifyError = useWorkspaceStore((s) => s.notifyError);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const copiedTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
useEffect(() => () => { if (copiedTimer.current) clearTimeout(copiedTimer.current); }, []);
|
||||||
const copy = async () => {
|
const copy = async () => {
|
||||||
await navigator.clipboard.writeText(code);
|
try {
|
||||||
notify({ kind: "success", title: "已复制", message: "SQL 已复制", mode: "toast", durationMs: 1200 });
|
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 insertToEditor = () => {
|
||||||
const st = useWorkspaceStore.getState();
|
const st = useWorkspaceStore.getState();
|
||||||
@@ -61,7 +69,7 @@ function CodeBlock({ code }: { code: string }) {
|
|||||||
style={{ borderColor: "var(--ops-border-soft)" }}
|
style={{ borderColor: "var(--ops-border-soft)" }}
|
||||||
>
|
>
|
||||||
<button type="button" className="text-[10px] hover:underline" style={{ color: "var(--ops-accent)" }} onClick={() => void copy()}>
|
<button type="button" className="text-[10px] hover:underline" style={{ color: "var(--ops-accent)" }} onClick={() => void copy()}>
|
||||||
复制
|
{copied ? "已复制 ✓" : "复制"}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="text-[10px] hover:underline" style={{ color: "var(--ops-accent)" }} onClick={insertToEditor}>
|
<button type="button" className="text-[10px] hover:underline" style={{ color: "var(--ops-accent)" }} onClick={insertToEditor}>
|
||||||
插入编辑器
|
插入编辑器
|
||||||
@@ -161,12 +169,16 @@ function DangerConfirmBar({
|
|||||||
connectionId,
|
connectionId,
|
||||||
database,
|
database,
|
||||||
kind,
|
kind,
|
||||||
|
confirmBind,
|
||||||
|
runId,
|
||||||
onDone,
|
onDone,
|
||||||
}: {
|
}: {
|
||||||
sql: string;
|
sql: string;
|
||||||
connectionId?: string;
|
connectionId?: string;
|
||||||
database?: string;
|
database?: string;
|
||||||
kind: "query" | "redis";
|
kind: "query" | "redis";
|
||||||
|
confirmBind?: string;
|
||||||
|
runId?: string;
|
||||||
onDone: (ok: boolean, msg?: string) => void;
|
onDone: (ok: boolean, msg?: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -180,6 +192,8 @@ function DangerConfirmBar({
|
|||||||
command: kind === "redis" ? sql : undefined,
|
command: kind === "redis" ? sql : undefined,
|
||||||
database,
|
database,
|
||||||
kind,
|
kind,
|
||||||
|
confirmBind,
|
||||||
|
runId,
|
||||||
});
|
});
|
||||||
onDone(true);
|
onDone(true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -528,6 +542,7 @@ export function AiPanel() {
|
|||||||
content: final.content,
|
content: final.content,
|
||||||
steps: final.steps || [],
|
steps: final.steps || [],
|
||||||
streaming: false,
|
streaming: false,
|
||||||
|
runId: final.runId,
|
||||||
});
|
});
|
||||||
if (final.sql && tab) updateTabSqlIfUnchanged(tab.id, sql, tab.connectionId, tab.database, final.sql);
|
if (final.sql && tab) updateTabSqlIfUnchanged(tab.id, sql, tab.connectionId, tab.database, final.sql);
|
||||||
},
|
},
|
||||||
@@ -674,6 +689,8 @@ export function AiPanel() {
|
|||||||
connectionId={conn?.id}
|
connectionId={conn?.id}
|
||||||
database={database || undefined}
|
database={database || undefined}
|
||||||
kind={pendingConfirm.step.tool?.includes("redis") ? "redis" : "query"}
|
kind={pendingConfirm.step.tool?.includes("redis") ? "redis" : "query"}
|
||||||
|
confirmBind={pendingConfirm.step.confirmBind}
|
||||||
|
runId={m.runId}
|
||||||
onDone={(ok, msg) => {
|
onDone={(ok, msg) => {
|
||||||
if (ok) {
|
if (ok) {
|
||||||
patchAiMessage(m.id, (cur) => ({
|
patchAiMessage(m.id, (cur) => ({
|
||||||
|
|||||||
@@ -540,6 +540,12 @@ export type AiAgentRes = {
|
|||||||
sql?: string;
|
sql?: string;
|
||||||
steps: AiAgentStep[];
|
steps: AiAgentStep[];
|
||||||
rounds: number;
|
rounds: number;
|
||||||
|
runId?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
runOutcome?: string;
|
||||||
|
incompleteReason?: string;
|
||||||
|
resumable?: boolean;
|
||||||
|
stepsBudget?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function callAiAgent(payload: {
|
export async function callAiAgent(payload: {
|
||||||
@@ -564,6 +570,7 @@ export type AiAgentStepExt = AiAgentStep & {
|
|||||||
pending?: boolean;
|
pending?: boolean;
|
||||||
needConfirm?: boolean;
|
needConfirm?: boolean;
|
||||||
confirmSql?: string;
|
confirmSql?: string;
|
||||||
|
confirmBind?: string;
|
||||||
result?: AiAgentQueryResult;
|
result?: AiAgentQueryResult;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -691,6 +698,8 @@ export async function confirmAiAgentWrite(payload: {
|
|||||||
command?: string;
|
command?: string;
|
||||||
database?: string;
|
database?: string;
|
||||||
kind?: "query" | "redis";
|
kind?: "query" | "redis";
|
||||||
|
confirmBind?: string;
|
||||||
|
runId?: string;
|
||||||
}) {
|
}) {
|
||||||
return post<Record<string, unknown>>("/api/ai/agent/confirm", payload);
|
return post<Record<string, unknown>>("/api/ai/agent/confirm", payload);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -207,6 +207,7 @@ export type AiAgentStepView = {
|
|||||||
pending?: boolean;
|
pending?: boolean;
|
||||||
needConfirm?: boolean;
|
needConfirm?: boolean;
|
||||||
confirmSql?: string;
|
confirmSql?: string;
|
||||||
|
confirmBind?: string;
|
||||||
result?: AiAgentQueryResultView;
|
result?: AiAgentQueryResultView;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -215,6 +216,7 @@ export type AiMessage = {
|
|||||||
role: "user" | "assistant" | "system";
|
role: "user" | "assistant" | "system";
|
||||||
content: string;
|
content: string;
|
||||||
at: string;
|
at: string;
|
||||||
|
runId?: string;
|
||||||
/** Agent 工具步骤时间线(仅 assistant) */
|
/** Agent 工具步骤时间线(仅 assistant) */
|
||||||
steps?: AiAgentStepView[];
|
steps?: AiAgentStepView[];
|
||||||
streaming?: boolean;
|
streaming?: boolean;
|
||||||
@@ -296,6 +298,8 @@ type State = {
|
|||||||
detail?: string;
|
detail?: string;
|
||||||
tips?: string[];
|
tips?: string[];
|
||||||
mode?: "modal" | "toast";
|
mode?: "modal" | "toast";
|
||||||
|
/** 成功操作默认静默;长任务完成等需要用户注意时显式设为 visible。 */
|
||||||
|
feedback?: "visible";
|
||||||
durationMs?: number;
|
durationMs?: number;
|
||||||
raw?: unknown;
|
raw?: unknown;
|
||||||
}) => void;
|
}) => void;
|
||||||
@@ -534,6 +538,7 @@ export const useWorkspaceStore = create<State>((set, get) => ({
|
|||||||
const parsed = input.raw !== undefined ? formatDbError(input.raw) : null;
|
const parsed = input.raw !== undefined ? formatDbError(input.raw) : null;
|
||||||
const kind = input.kind ?? parsed?.kind ?? "info";
|
const kind = input.kind ?? parsed?.kind ?? "info";
|
||||||
const mode = input.mode ?? (kind === "error" ? "modal" : "toast");
|
const mode = input.mode ?? (kind === "error" ? "modal" : "toast");
|
||||||
|
if (kind === "success" && mode === "toast" && input.feedback !== "visible") return;
|
||||||
const payload: NotifyPayload = {
|
const payload: NotifyPayload = {
|
||||||
id: notifyId(),
|
id: notifyId(),
|
||||||
kind,
|
kind,
|
||||||
|
|||||||
Reference in new issue
Block a user