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
12 changes: 11 additions & 1 deletion .agents/skills/xsql/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ description: >
- Use additional attributes to make usage searchable: `agent` (for example `codex`), `env` (for example `prod`), `team`, and `task` are recommended when known.
- `stdout` is data; `stderr` is logs. Never parse stderr as result data.
- Validate responses by checking `ok`, `schema_version`, and `error.code` — not by string matching.
- On `ok: false`, read `error.details.hint`, `error.details.category`, and `error.details.retryable` before retrying (see `docs/error-contract.md`). Use `hint` as the next action; respect `retryable: false` for SQL/schema/permission fixes instead of blind retries.
- Assume read-only mode. Writes require both `unsafe_allow_write: true` in the selected profile and a runtime write opt-in: `--unsafe-allow-write` on `xsql query` / `xsql ai`, or `--unsafe-allow-write` when starting `xsql mcp server` (or `XSQL_MCP_UNSAFE_ALLOW_WRITE=true`). Only enable both factors when the user has explicitly requested writes.
- Never leak secrets, full DSNs, passwords, or private keys in output or summaries.
- `--ssh-skip-known-hosts-check` is a risky last resort — call out the security tradeoff if it's needed.
Expand Down Expand Up @@ -137,7 +138,16 @@ JSON responses follow this envelope:
{"ok": false, "schema_version": 1, "error": {"code": "...", "message": "...", "details": {...}}}
```

Handle failures by `error.code` and exit status:
Structured guidance in `error.details` (when present):

| Field | Use |
|-------|-----|
| `hint` | Short next step (e.g. run `schema dump`, fix syntax, check profile/ssh_proxy) |
| `category` | Stable class: `unknown_table`, `syntax_error`, `read_only_blocked`, `unrecognized_statement`, `auth_failed`, `permission_denied`, `connection`, … |
| `retryable` | If `false`, change inputs or config before retrying |
| `native_code` | MySQL errno or PostgreSQL SQLSTATE when available |

Handle failures by `error.code`, `details.category`, and exit status:

| Exit Code | Meaning |
|-----------|---------|
Expand Down
1 change: 1 addition & 0 deletions docs/ai.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
## 规范建议
- 非 TTY 默认输出 JSON;TTY 默认 table。
- 错误对象:`code/message/details`;并保证退出码与 code 对应。
- 失败时优先读取 `error.details.hint`、`error.details.category` 与 `error.details.retryable`(见 `docs/error-contract.md` §2.1),再决定是否改 SQL、拉 schema、修连接或开启写入;不要仅根据 `message` 字符串盲重试。
- 提供 `xsql spec --format json` 导出:
- commands/flags/env mapping
- output schema
Expand Down
58 changes: 47 additions & 11 deletions docs/error-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,44 @@
字段含义:
- `code`:稳定错误码(字符串,供程序判断)
- `message`:面向人的简短描述(可本地化,但 `code` 不变)
- `details`:结构化细节(可选),用于调试/自动化处理
- **注意**:`cause` 字段不在 JSON/YAML 中暴露,仅在错误字符串输出(`Error()`)和错误解包(`Unwrap()`)中可用
- `details`:结构化细节(可选),用于调试/自动化处理
- **注意**:`cause` 字段不在 JSON/YAML 中暴露,仅在错误字符串输出(`Error()`)和错误解包(`Unwrap()`)中可用

### 2.1 AI/agent 指引字段(`details` 内,只增不改)

以下字段由 `internal/errors` 在 `New`/`Wrap` 时自动合并(不覆盖调用方已设置的同名键):

| 字段 | 类型 | 说明 |
|------|------|------|
| `hint` | string | 面向 agent 的简短下一步建议(英文) |
| `retryable` | bool | 是否建议在相同输入下立即重试 |
| `category` | string | 稳定语义分类,如 `unknown_table`、`syntax_error`、`read_only_blocked`、`unrecognized_statement`、`auth_failed`、`permission_denied`、`connection` |
| `dialect` | string | 可选,`mysql` 或 `pg` |
| `native_code` | string | 可选,MySQL errno 或 PostgreSQL SQLSTATE |
| `native_code_kind` | string | 可选,`mysql_errno` 或 `pg_sqlstate` |

只读拦截等既有字段(如 `reason`)保持不变。`schema_version` 仍为 `1`(仅追加 `details` 字段,不 bump 版本号)。

示例(未知表):

```json
{
"ok": false,
"schema_version": 1,
"error": {
"code": "XSQL_DB_EXEC_FAILED",
"message": "query failed",
"details": {
"dialect": "mysql",
"category": "unknown_table",
"native_code": "1146",
"native_code_kind": "mysql_errno",
"retryable": false,
"hint": "Table or database not found; run `xsql schema dump` ..."
}
}
}
```

安全约束:
- `details` 中不得包含明文密码、私钥、passphrase、完整 DSN/URL(可脱敏)。
Expand Down Expand Up @@ -55,15 +91,15 @@ DB 类:
只读策略:
- `XSQL_RO_BLOCKED` - 写操作被只读策略拦截

端口:
- `XSQL_PORT_IN_USE` - 代理端口被占用

鉴权:
- `XSQL_AUTH_REQUIRED` - 请求缺少鉴权 token
- `XSQL_AUTH_INVALID` - 请求提供的鉴权 token 无效

内部:
- `XSQL_INTERNAL` - 内部错误
端口:
- `XSQL_PORT_IN_USE` - 代理端口被占用
鉴权:
- `XSQL_AUTH_REQUIRED` - 请求缺少鉴权 token
- `XSQL_AUTH_INVALID` - 请求提供的鉴权 token 无效
内部:
- `XSQL_INTERNAL` - 内部错误

## 5. 查询结果格式

Expand Down
39 changes: 39 additions & 0 deletions docs/rfcs/0016-error-guidance-for-agents.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# RFC 0016: AI Agent 错误指引(Error Guidance)

Status: Accepted

## 摘要
为 xsql 的 JSON/YAML 错误 `details` 增加面向 AI/agent 的稳定指引字段(`hint`、`retryable`、`category`、方言与原生 DB 错误码),在不改变顶层 `error.code`、退出码与 `schema_version` 的前提下,减少 agent 对 DB/连接/只读类错误的无效重试。

## 背景 / 动机
- 当前痛点:`XSQL_DB_EXEC_FAILED` 等错误仅携带驱动原始 message,agent 难以判断应改 SQL、查 schema、修连接还是开启写入。
- 目标:在 `error.details` 中追加可机读、可行动的指引;MySQL errno 与 PostgreSQL SQLSTATE 映射到少量稳定 `category`。
- 非目标:不改变错误码含义、不提升 `schema_version`、不在 details 中暴露密码/完整 DSN。

## 方案(Proposed)
### 用户视角(CLI/配置/输出)
- 失败响应仍为 `{"ok":false,"schema_version":1,"error":{"code","message","details"}}`。
- `details` **只增**字段(已有键如 `reason` 保留):
- `hint` (string):简短下一步建议(英文,便于模型直接遵循)。
- `retryable` (bool):是否建议在相同输入下立即重试。
- `category` (string):稳定语义分类(如 `unknown_table`、`syntax_error`、`read_only_blocked`)。
- `dialect` (string, 可选):`mysql` | `pg`。
- `native_code` (string, 可选):MySQL errno 或 PostgreSQL SQLSTATE。
- `native_code_kind` (string, 可选):`mysql_errno` | `pg_sqlstate`。

### 技术设计(Architecture)
- `internal/errors`:`New`/`Wrap` 返回前调用 `enrichDetails`,根据 `code`、`details` 与 `cause` 合并指引(不覆盖调用方已设置的 details 键)。
- `internal/errors/dbclassify.go`:解析 `go-sql-driver/mysql.MySQLError` 与 `pgconn.PgError`。
- `internal/db/query.go`:执行失败时在 details 传入 `dialect`。
- CLI / MCP / Web 共用 `internal/errors` 与 `internal/output`,无需各自 duplicate。

## 兼容性与迁移
- 不破坏兼容:仅追加 `details` 字段;`schema_version` 保持 `1`。
- 无迁移步骤。

## 测试计划
- 单元测试:MySQL/PG 典型 errno/SQLSTATE → category/hint/retryable。
- 集成测试(Docker MySQL 8.0 / PG 16):未知表、语法错误、只读拦截产生预期 `hint`。

## 安全与隐私
- `hint`/`category` 不含 secrets;`native_code` 为公开错误码,非连接串。
1 change: 1 addition & 0 deletions docs/rfcs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
| [0013](0013-ai-multi-tool-calls-sequential-execution.md) | AI Multi Tool Call Sequential Execution & Dialect Guidance | Implemented |
| [0014](0014-mcp-write-dual-authorization.md) | MCP 写入双重授权 | Implemented |
| [0015](0015-mcp-app-layer-schema-tools.md) | MCP 复用 app 层与 Schema Tools | Implemented |
| [0016](0016-error-guidance-for-agents.md) | AI Agent 错误指引(Error Guidance) | Accepted |

## 何时需要 RFC
当变更会影响以下任一项时,必须先提交/更新 RFC(并同步更新对应 `docs/*`):
Expand Down
4 changes: 2 additions & 2 deletions internal/db/mysql/driver.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,14 +95,14 @@ func (d *Driver) Open(ctx context.Context, opts db.ConnOptions) (*sql.DB, *error
dsn := cfg.FormatDSN()
conn, err := sql.Open("mysql", dsn)
if err != nil {
return nil, errors.Wrap(errors.CodeDBConnectFailed, "failed to open mysql connection", nil, err)
return nil, errors.Wrap(errors.CodeDBConnectFailed, "failed to open mysql connection", map[string]any{"dialect": "mysql"}, err)
}
if err := conn.PingContext(ctx); err != nil {
if closeErr := conn.Close(); closeErr != nil {
log.Printf("failed to close mysql connection: %v", closeErr)
}
cleanupDialContext(dialName)
return nil, errors.Wrap(errors.CodeDBConnectFailed, "failed to ping mysql", nil, err)
return nil, errors.Wrap(errors.CodeDBConnectFailed, "failed to ping mysql", map[string]any{"dialect": "mysql"}, err)
}
return conn, nil
}
6 changes: 3 additions & 3 deletions internal/db/pg/driver.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,20 +42,20 @@ func (d *Driver) Open(ctx context.Context, opts db.ConnOptions) (*sql.DB, *error
if closeErr := conn.Close(); closeErr != nil {
log.Printf("failed to close pg connection: %v", closeErr)
}
return nil, errors.Wrap(errors.CodeDBConnectFailed, "failed to ping pg", nil, err)
return nil, errors.Wrap(errors.CodeDBConnectFailed, "failed to ping pg", map[string]any{"dialect": "pg"}, err)
}
return conn, nil
}

conn, err := sql.Open("pgx", dsn)
if err != nil {
return nil, errors.Wrap(errors.CodeDBConnectFailed, "failed to open pg connection", nil, err)
return nil, errors.Wrap(errors.CodeDBConnectFailed, "failed to open pg connection", map[string]any{"dialect": "pg"}, err)
}
if err := conn.PingContext(ctx); err != nil {
if closeErr := conn.Close(); closeErr != nil {
log.Printf("failed to close pg connection: %v", closeErr)
}
return nil, errors.Wrap(errors.CodeDBConnectFailed, "failed to ping pg", nil, err)
return nil, errors.Wrap(errors.CodeDBConnectFailed, "failed to ping pg", map[string]any{"dialect": "pg"}, err)
}
return conn, nil
}
Expand Down
17 changes: 13 additions & 4 deletions internal/db/query.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ type QueryOptions struct {
func Query(ctx context.Context, db *sql.DB, query string, opts QueryOptions) (*QueryResult, *errors.XError) {
// UnsafeAllowWrite bypasses all read-only protections
if opts.UnsafeAllowWrite {
return executeQuery(ctx, db, query)
return executeQuery(ctx, db, query, opts.DBType)
}

// Enable dual read-only protection by default
Expand Down Expand Up @@ -100,18 +100,18 @@ func queryWithReadOnlyTx(ctx context.Context, db *sql.DB, query string, dbType s

rows, err := tx.QueryContext(ctx, query)
if err != nil {
return nil, errors.Wrap(errors.CodeDBExecFailed, "query failed", nil, err)
return nil, wrapQueryFailed(dbType, err)
}
defer rows.Close()

return scanRows(rows)
}

// executeQuery executes a query directly (without a transaction).
func executeQuery(ctx context.Context, db *sql.DB, query string) (*QueryResult, *errors.XError) {
func executeQuery(ctx context.Context, db *sql.DB, query string, dbType string) (*QueryResult, *errors.XError) {
rows, err := db.QueryContext(ctx, query)
if err != nil {
return nil, errors.Wrap(errors.CodeDBExecFailed, "query failed", nil, err)
return nil, wrapQueryFailed(dbType, err)
}
defer rows.Close()

Expand Down Expand Up @@ -147,6 +147,15 @@ func scanRows(rows *sql.Rows) (*QueryResult, *errors.XError) {
return result, nil
}

func wrapQueryFailed(dbType string, err error) *errors.XError {
dialect := normalizeDBType(dbType)
details := map[string]any{}
if dialect != "" {
details["dialect"] = dialect
}
return errors.Wrap(errors.CodeDBExecFailed, "query failed", details, err)
}

func convertValue(v any) any {
switch val := v.(type) {
case []byte:
Expand Down
4 changes: 2 additions & 2 deletions internal/db/query_readonly_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ func TestExecuteQuery_Success(t *testing.T) {
rows := sqlmock.NewRows([]string{"msg"}).AddRow([]byte("hi"))
mock.ExpectQuery("SELECT 'hi'").WillReturnRows(rows)

result, xe := executeQuery(context.Background(), db, "SELECT 'hi'")
result, xe := executeQuery(context.Background(), db, "SELECT 'hi'", "mysql")
if xe != nil {
t.Fatalf("executeQuery failed: %v", xe)
}
Expand All @@ -243,7 +243,7 @@ func TestExecuteQuery_Failure(t *testing.T) {

mock.ExpectQuery("SELECT 1").WillReturnError(errors.New("boom"))

_, xe := executeQuery(context.Background(), db, "SELECT 1")
_, xe := executeQuery(context.Background(), db, "SELECT 1", "mysql")
if xe == nil || xe.Code != xsqlerrors.CodeDBExecFailed {
t.Fatalf("expected failure, got %v", xe)
}
Expand Down
Loading
Loading