Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions docs/ai.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,21 +117,23 @@ xsql ai --profile dev --attr source=codex-cli --attr agent=codex --attr env=dev
`xsql ai` 默认保持只读。只有所选 profile 配置了 `unsafe_allow_write: true`,且本次启动同时携带 `--unsafe-allow-write` 时,TUI 才进入 READ-WRITE 模式;切换 profile 后会重新校验这两个条件。

### LLM 集成与 Tool Call 机制
`xsql` 使用 OpenAI 官方 SDK (`github.com/openai/openai-go`) 与大模型交互,基于标准的 **ReAct Agent Loop 循环推理**,支持 3 大核心 Tools 调度:
`xsql` 使用 OpenAI 官方 SDK (`github.com/openai/openai-go`) 与大模型交互,基于标准的 **ReAct Agent Loop 循环推理**,支持 4 大核心 Tools 调度:
1. **`execute_sql(sql: string, explanation: string)`**: 数据库 SQL 查询工具(执行成功后宿主自动渲染内嵌交互表格)。
2. **`execute_javascript(js_code: string, explanation: string)`**: 基于 `goja` 沙箱的本地 JS 数据聚合计算工具(必须遵循 ES5 语法)。
3. **`export_data(dataset_id: string, format: string, filepath: string, explanation: string)`**: 会话数据集文件导出工具(触发人机交互二次确认)。
3. **`export_data(dataset_id: string, format: string, filepath: string, explanation: string)`**: 原始数据集转储工具,仅支持 `csv` 和 `json` 格式(触发人机交互二次确认,路径支持 `~/` 展开)。
4. **`export_report(content: string, filepath: string, explanation: string)`**: 富文本 Markdown 分析报告导出工具(触发人机交互二次确认,路径支持 `~/` 展开)。

#### ReAct Agent Loop 准则
- **循环驱动**:Agent 会在单次交互中循环执行 Tools,直到不再产生 Tool Call。
- **自愈重试机制**:当模型返回的 Tool Call 参数格式不符合 JSON Schema 时,Agent 循环自动捕获并将格式错误反馈给模型,触发自动修复与重试(最大 2 次)。
- **最终回答不变性**:交互轮次的最终输出必定是 AI 总结出的自然语言 / Markdown 格式分析报告。
- **工具折叠与容器内嵌**:所有的中间 Tool Call 默认以单行 Pill 收起折叠(内嵌表格与指标数据),界面保持极简清爽。

#### 有界数据回传与 Session 数据集召回 (Session DataStore)
- 每次查询成功的结果在本地分配标号(`res1`, `res2`, ...)。
- 大模型上下文包含数据集的轻量 Catalog 目录结构(字段名与行数),不会自动加入完整查询结果。
- 本地 JavaScript 的派生结果会以最多 4096 个字符的摘要回传给模型,用于生成最终分析;超出部分会截断并明确标记。
- AI 可通过 `execute_javascript` 生成纯 Go 沙箱 (`goja`) 执行的代码,在本地对 `res1`, `res2` 等数据集做跨表 Join、占比统计与数据清洗,并通过 `export_data` 安全导出为 CSV/JSON/Markdown。
- AI 可通过 `execute_javascript` 生成纯 Go 沙箱 (`goja`) 执行的代码,在本地对 `res1`, `res2` 等数据集做跨表 Join、占比统计与数据清洗,并通过 `export_data` 安全导出为 CSV/JSON,或通过 `export_report` 导出完整分析报告。

### 快捷键操作

Expand Down
4 changes: 2 additions & 2 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ ai:
api_key: "keyring:ai/api_key"
allow_plaintext: false
model: gpt-4o
max_tokens: 2048
max_tokens: 8192

stats:
enabled: true
Expand Down Expand Up @@ -133,7 +133,7 @@ profiles:
| `ai.api_key` | string | AI 服务 API Key,推荐使用 `keyring:` 引用 |
| `ai.allow_plaintext` | bool | 允许配置文件中的明文 AI API Key(默认 false) |
| `ai.model` | string | 模型名称,默认 `gpt-4o` |
| `ai.max_tokens` | int | 单次响应最大 token 数,默认 2048 |
| `ai.max_tokens` | int | 单次响应最大 token 数,默认 8192 |

配置文件中的明文 `ai.api_key` 默认拒绝。设置 `ai.allow_plaintext: true` 或在 `xsql ai` 显式使用 `--allow-plaintext` 时允许;CLI `--api-key` 和 `XSQL_AI_API_KEY` 本身属于显式运行时输入。

Expand Down
15 changes: 15 additions & 0 deletions docs/rfcs/0013-ai-multi-tool-calls-sequential-execution.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,18 @@ type AIResponse struct {
### 3. System Prompt 数据库上下文与调用规范 (`internal/ai/prompt.go`)
- 注入 `TARGET DATABASE: %s (Dialect: %s)` 显式标识当前连接数据库与方言。
- 规范 Agent 调用行为:在执行 SQL、运行 JS 分析或导出文件时统一使用标准的结构化 Tool Calling 协议。

---

## 修订记录 (Revision History)

### 2026-08: 报告导出工具拆分、严格 Schema 校验自动重试与路径展开
1. **文件导出职责分离**:
- `export_data` 专注于原始数据集转储,仅支持 `csv` 和 `json` 格式,不再支持 `markdown`。
- 新增 `export_report(content, filepath, explanation)` 工具,专用于将 LLM 总结生成的结构化富文本 Markdown 报告写入本地文件。
2. **路径自动展开 (Tilde Expansion)**:
- 导出路径支持 `~/` 波浪号展开为用户家目录(`$HOME`),防止误拼接为相对路径。
3. **严格 Schema 校验与自愈重试**:
- 保持严格 JSON 反序列化校验。
- 当模型返回非法/畸形 Tool Call 参数时,由 TUI Agent 循环捕获并向上下文注入错误反馈,自动触发重试(最大 2 次),无需人工干预输入“继续”。

60 changes: 55 additions & 5 deletions internal/ai/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package ai
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"

Expand Down Expand Up @@ -104,7 +105,7 @@ func (c *Client) ChatCompletion(ctx context.Context, messages []ChatMessage) (*A
exportToolDef := openai.ChatCompletionToolParam{
Function: shared.FunctionDefinitionParam{
Name: "export_data",
Description: openai.String("Export a cached session dataset (e.g. res1, res2) to a local file in CSV, JSON, or Markdown format after human confirmation."),
Description: openai.String("Export a cached session dataset (e.g. res1, res2) to a local file in CSV or JSON format after human confirmation."),
Parameters: shared.FunctionParameters{
"type": "object",
"properties": map[string]interface{}{
Expand All @@ -114,8 +115,8 @@ func (c *Client) ChatCompletion(ctx context.Context, messages []ChatMessage) (*A
},
"format": map[string]interface{}{
"type": "string",
"description": "Export file format: 'csv', 'json', or 'markdown'.",
"enum": []string{"csv", "json", "markdown"},
"description": "Export file format: 'csv' or 'json'.",
"enum": []string{"csv", "json"},
},
"filepath": map[string]interface{}{
"type": "string",
Expand All @@ -131,6 +132,31 @@ func (c *Client) ChatCompletion(ctx context.Context, messages []ChatMessage) (*A
},
}

reportToolDef := openai.ChatCompletionToolParam{
Function: shared.FunctionDefinitionParam{
Name: "export_report",
Description: openai.String("Export a comprehensive Markdown analysis report to a local file after human confirmation."),
Parameters: shared.FunctionParameters{
"type": "object",
"properties": map[string]interface{}{
"content": map[string]interface{}{
"type": "string",
"description": "The complete Markdown text content of the analysis report.",
},
"filepath": map[string]interface{}{
"type": "string",
"description": "Target file path (e.g. 'summary_report.md', '~/Downloads/report.md').",
},
"explanation": map[string]interface{}{
"type": "string",
"description": "Brief explanation of the report being saved.",
},
},
"required": []string{"content", "filepath", "explanation"},
},
},
}

model := c.cfg.Model
if model == "" {
model = "gpt-4o"
Expand All @@ -139,7 +165,7 @@ func (c *Client) ChatCompletion(ctx context.Context, messages []ChatMessage) (*A
params := openai.ChatCompletionNewParams{
Model: shared.ChatModel(model),
Messages: sdkMessages,
Tools: []openai.ChatCompletionToolParam{sqlToolDef, jsToolDef, exportToolDef},
Tools: []openai.ChatCompletionToolParam{sqlToolDef, jsToolDef, exportToolDef, reportToolDef},
ParallelToolCalls: openai.Bool(false),
}
if c.cfg.MaxTokens > 0 {
Expand All @@ -158,6 +184,11 @@ func (c *Client) ChatCompletion(ctx context.Context, messages []ChatMessage) (*A
}

choice := resp.Choices[0]
if choice.FinishReason == "length" {
return nil, errors.New(errors.CodeInternal, "AI response was truncated because it exceeded output token limit (max_tokens). Please provide a more concise report/summary without embedding excessively large raw datasets.", map[string]any{
"finish_reason": choice.FinishReason,
})
}
msg := choice.Message

if len(msg.ToolCalls) > 0 {
Expand Down Expand Up @@ -213,6 +244,23 @@ func (c *Client) ChatCompletion(ctx context.Context, messages []ChatMessage) (*A
Explanation: strings.TrimSpace(raw.Explanation),
})

case "export_report":
var raw struct {
Content string `json:"content"`
FilePath string `json:"filepath"`
Explanation string `json:"explanation"`
}
if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &raw); err != nil {
return nil, invalidToolArguments(toolCall.Function.Name, err)
}
actions = append(actions, ToolAction{
ID: toolCall.ID,
Type: TypeReport,
Content: strings.TrimSpace(raw.Content),
FilePath: strings.TrimSpace(raw.FilePath),
Explanation: strings.TrimSpace(raw.Explanation),
})

default:
return nil, errors.New(errors.CodeInternal, "AI provider returned an unsupported tool call", map[string]any{
"tool": toolCall.Function.Name,
Expand All @@ -228,6 +276,7 @@ func (c *Client) ChatCompletion(ctx context.Context, messages []ChatMessage) (*A
DatasetID: first.DatasetID,
Format: first.Format,
FilePath: first.FilePath,
Content: first.Content,
Explanation: first.Explanation,
Actions: actions,
}, nil
Expand All @@ -242,7 +291,8 @@ func (c *Client) ChatCompletion(ctx context.Context, messages []ChatMessage) (*A
}

func invalidToolArguments(toolName string, err error) *errors.XError {
return errors.New(errors.CodeInternal, "AI provider returned invalid tool arguments", map[string]any{
msg := fmt.Sprintf("Invalid JSON arguments for tool '%s': %v. Ensure all string properties (especially multiline JS code and Markdown reports) are properly JSON-escaped with valid '\\n' newlines and escaped quotes.", toolName, err)
return errors.New(errors.CodeInternal, msg, map[string]any{
"tool": toolName,
"err": err.Error(),
})
Expand Down
6 changes: 5 additions & 1 deletion internal/ai/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ DATABASE SCHEMA:
ENVIRONMENT & SPECIFICATIONS:
- Database Mode: Default to READ-ONLY SELECT queries.
- JavaScript Environment: Strict ES5 (ECMAScript 5.1) engine. Active session datasets (e.g. res1, res2) are available in global context.
- Output: Always use the structured tool calling interface when executing SQL, running JS analysis, or exporting files.
* Avoid modern Intl-dependent methods (e.g. do not pass locale string arguments to Number.prototype.toLocaleString).
- Tool Calling Guidelines:
* Always use the structured tool calling interface with strictly valid JSON arguments (all newlines and double quotes in code/text strings must be properly escaped).
* 'export_data': Use ONLY to export a raw cached session dataset (e.g. res1, res2) to 'csv' or 'json'.
* 'export_report': When the user asks to generate, save, or export an analysis report / summary / Markdown document, assemble the comprehensive Markdown content (including titles, insights, conclusions, and key aggregated tables) and call 'export_report'. Focus on concise analytical conclusions and avoid dumping excessively large raw records.
`

func FormatDBName(dbType string) string {
Expand Down
3 changes: 3 additions & 0 deletions internal/ai/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const (
TypeSQL ResponseType = "sql"
TypeJS ResponseType = "js"
TypeExport ResponseType = "export"
TypeReport ResponseType = "report"
TypeText ResponseType = "text"
)

Expand All @@ -29,6 +30,7 @@ type ToolAction struct {
DatasetID string `json:"dataset_id,omitempty"`
Format string `json:"format,omitempty"`
FilePath string `json:"filepath,omitempty"`
Content string `json:"content,omitempty"`
Explanation string `json:"explanation,omitempty"`
}

Expand All @@ -39,6 +41,7 @@ type AIResponse struct {
DatasetID string `json:"dataset_id,omitempty"`
Format string `json:"format,omitempty"`
FilePath string `json:"filepath,omitempty"`
Content string `json:"content,omitempty"`
Explanation string `json:"explanation"`
Actions []ToolAction `json:"actions,omitempty"`
}
Expand Down
145 changes: 145 additions & 0 deletions internal/ai/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,64 @@ func TestGenerateResponse_MockHTTP_JSToolCall(t *testing.T) {
}
}

func TestGenerateResponse_MockHTTP_ExportReportToolCall(t *testing.T) {
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
respBody := `{
"id": "chatcmpl-126",
"object": "chat.completion",
"created": 1677652288,
"model": "gpt-4o",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_report123",
"type": "function",
"function": {
"name": "export_report",
"arguments": "{\"content\":\"# Analysis Report\\n\\nAll good.\",\"filepath\":\"~/Downloads/report.md\",\"explanation\":\"Exports daily markdown report.\"}"
}
}
]
},
"finish_reason": "tool_calls"
}
]
}`
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(respBody))
}))
defer mockServer.Close()

cfg := config.AIConfig{
Provider: "openai",
BaseURL: mockServer.URL,
APIKey: "test-key",
}

client := NewClient(cfg, mockServer.Client())
service := NewService(cfg, client)

res, xe := service.GenerateResponse(context.Background(), "export report", nil, "mysql", "res1 catalog")
if xe != nil {
t.Fatalf("unexpected error: %v", xe)
}

if res.Type != TypeReport {
t.Errorf("expected type Report, got %q", res.Type)
}
if res.Content != "# Analysis Report\n\nAll good." {
t.Errorf("unexpected content: %q", res.Content)
}
if res.FilePath != "~/Downloads/report.md" {
t.Errorf("unexpected filepath: %q", res.FilePath)
}
}

func TestGenerateSQL_MockHTTP_TextMessageFallback(t *testing.T) {
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
respBody := `{
Expand Down Expand Up @@ -337,3 +395,90 @@ func TestService_ChatCompletion(t *testing.T) {
t.Errorf("expected explanation 'Hello from ChatCompletion', got %q", res.Explanation)
}
}

func TestChatCompletion_InvalidToolArgumentsAndEdgeCases(t *testing.T) {
testCases := []struct {
name string
respJSON string
wantErr string
}{
{
name: "invalid execute_sql arguments",
respJSON: `{
"id": "cmpl-1", "object": "chat.completion", "created": 1, "model": "gpt-4o",
"choices": [{"index": 0, "message": {"role": "assistant", "tool_calls": [{"id": "c1", "type": "function", "function": {"name": "execute_sql", "arguments": "invalid json"}}]}}]
}`,
wantErr: "Invalid JSON arguments for tool 'execute_sql'",
},
{
name: "invalid execute_javascript arguments",
respJSON: `{
"id": "cmpl-2", "object": "chat.completion", "created": 1, "model": "gpt-4o",
"choices": [{"index": 0, "message": {"role": "assistant", "tool_calls": [{"id": "c2", "type": "function", "function": {"name": "execute_javascript", "arguments": "{"}}]}}]
}`,
wantErr: "Invalid JSON arguments for tool 'execute_javascript'",
},
{
name: "invalid export_data arguments",
respJSON: `{
"id": "cmpl-3", "object": "chat.completion", "created": 1, "model": "gpt-4o",
"choices": [{"index": 0, "message": {"role": "assistant", "tool_calls": [{"id": "c3", "type": "function", "function": {"name": "export_data", "arguments": "not-json"}}]}}]
}`,
wantErr: "Invalid JSON arguments for tool 'export_data'",
},
{
name: "invalid export_report arguments",
respJSON: `{
"id": "cmpl-4", "object": "chat.completion", "created": 1, "model": "gpt-4o",
"choices": [{"index": 0, "message": {"role": "assistant", "tool_calls": [{"id": "c4", "type": "function", "function": {"name": "export_report", "arguments": "bad json"}}]}}]
}`,
wantErr: "Invalid JSON arguments for tool 'export_report'",
},
{
name: "unsupported tool call",
respJSON: `{
"id": "cmpl-5", "object": "chat.completion", "created": 1, "model": "gpt-4o",
"choices": [{"index": 0, "message": {"role": "assistant", "tool_calls": [{"id": "c5", "type": "function", "function": {"name": "unknown_tool", "arguments": "{}"}}]}}]
}`,
wantErr: "unsupported tool call",
},
{
name: "empty choices",
respJSON: `{
"id": "cmpl-6", "object": "chat.completion", "created": 1, "model": "gpt-4o",
"choices": []
}`,
wantErr: "empty choices",
},
{
name: "truncated by max_tokens limit",
respJSON: `{
"id": "cmpl-7", "object": "chat.completion", "created": 1, "model": "gpt-4o",
"choices": [{"index": 0, "finish_reason": "length", "message": {"role": "assistant", "content": "half message"}}]
}`,
wantErr: "exceeded output token limit",
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(tc.respJSON))
}))
defer server.Close()

cfg := config.AIConfig{Provider: "openai", BaseURL: server.URL, APIKey: "k"}
client := NewClient(cfg, server.Client())
svc := NewService(cfg, client)

_, xe := svc.ChatCompletion(context.Background(), []ChatMessage{{Role: "user", Content: "hi"}})
if xe == nil {
t.Fatalf("expected error containing %q, got nil", tc.wantErr)
}
if !strings.Contains(xe.Message, tc.wantErr) {
t.Fatalf("expected error containing %q, got %q", tc.wantErr, xe.Message)
}
})
}
}
2 changes: 1 addition & 1 deletion internal/config/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ func Resolve(opts Options) (Resolved, *errors.XError) {
aiConfig.Model = "gpt-4o"
}
if aiConfig.MaxTokens == 0 {
aiConfig.MaxTokens = 2048
aiConfig.MaxTokens = 8192
}

if opts.EnvAIBaseURL != "" {
Expand Down
2 changes: 1 addition & 1 deletion internal/config/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ type AIConfig struct {
APIKey string `yaml:"api_key" json:"api_key"` // supports keyring:xxx reference
AllowPlaintext bool `yaml:"allow_plaintext" json:"allow_plaintext"` // allow plaintext API key
Model string `yaml:"model" json:"model"` // default "gpt-4o"
MaxTokens int `yaml:"max_tokens" json:"max_tokens"` // default 2048
MaxTokens int `yaml:"max_tokens" json:"max_tokens"` // default 8192
}

// SSHProxy defines a reusable SSH proxy configuration.
Expand Down
Loading
Loading