mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 04:35:15 +00:00
feat(ai-agent): Phase 3 — explain_query tool, parallel/sequential execution, DB gating (#1187)
- Add explain_query tool with structured EXPLAIN result, gated on supports_explain_plan() - Add supports_sql_query() to gate execute_query/get_sample_data for non-SQL DBs (Redis, ES, MongoDB, etc.) - Split agent loop tool dispatch: execute_query runs sequentially, all others run in parallel via join_all - Wire explain_query result button to ExplainPlanViewer in AiAssistant + App.vue - Fix O(n²) agentSteps rebuild: incremental O(1) push per event during streaming - finally block falls back to full rebuild only when streaming push did not occur
This commit is contained in:
+2
-1
@@ -60,4 +60,5 @@ releases-*.json
|
||||
test.pdb
|
||||
portable/
|
||||
DBX_*_x64-portable.zip
|
||||
.agents/skills
|
||||
.agents/skills
|
||||
.pnpm-store/
|
||||
@@ -870,6 +870,15 @@ function onAiRequestAutoExecuteSql(sql: string) {
|
||||
});
|
||||
}
|
||||
|
||||
function onAiOpenExplainPlan(sql: string) {
|
||||
const tabId = ensureQueryTab();
|
||||
queryStore.updateSql(tabId, sql);
|
||||
selectedSql.value = "";
|
||||
nextTick(() => {
|
||||
void tryExplain(sql);
|
||||
});
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.defaultPrevented) return;
|
||||
|
||||
@@ -1275,7 +1284,7 @@ onUnmounted(() => {
|
||||
<div v-if="showAiPanel" :class="isClassicLayout ? 'h-full shrink-0 relative z-30 isolate bg-background' : 'h-full shrink-0 relative z-30 isolate rounded-md border border-border/80 bg-background'" :style="{ width: aiPanelWidth + 'px' }">
|
||||
<div class="panel-resize-handle panel-resize-handle--left" @mousedown="startAiPanelResize" />
|
||||
<div class="h-full min-h-0 overflow-hidden">
|
||||
<AiAssistant v-if="aiPanelReady" ref="aiAssistantRef" :tab="activeTab" :connection="activeConnection" @replace-sql="onAiReplaceSql" @execute-sql="onAiExecuteSql" @request-auto-execute-sql="onAiRequestAutoExecuteSql" @close="toggleAiPanel" />
|
||||
<AiAssistant v-if="aiPanelReady" ref="aiAssistantRef" :tab="activeTab" :connection="activeConnection" @replace-sql="onAiReplaceSql" @execute-sql="onAiExecuteSql" @request-auto-execute-sql="onAiRequestAutoExecuteSql" @open-explain-plan="onAiOpenExplainPlan" @close="toggleAiPanel" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { computed, nextTick, onMounted, onUnmounted, ref, type Component } from
|
||||
import { uuid } from "@/lib/utils";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { translateBackendError } from "@/i18n/backend-errors";
|
||||
import { ArrowUp, ArrowRightLeft, AlertTriangle, Bot, Check, ChevronRight, CircleSlash, Copy, Database, HelpCircle, History, Loader2, MessageSquarePlus, Replace, Server, ShieldCheck, Table2, Play, Square, Trash2, Terminal, Wand2, Wrench, X, Zap, TestTube } from "@lucide/vue";
|
||||
import { ArrowUp, ArrowRightLeft, AlertTriangle, Bot, Check, ChevronRight, CircleSlash, Copy, Database, GitBranch, HelpCircle, History, Loader2, MessageSquarePlus, Replace, Server, ShieldCheck, Table2, Play, Square, Trash2, Terminal, Wand2, Wrench, X, Zap, TestTube } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
@@ -29,6 +29,8 @@ import type { ConnectionConfig, QueryTab, TableInfo } from "@/types/database";
|
||||
import { useDatabaseOptions } from "@/composables/useDatabaseOptions";
|
||||
import { decodeSelectableDatabaseValue, encodeSelectableDatabaseValue, formatDatabaseLabel, resolveDefaultDatabase } from "@/lib/defaultDatabase";
|
||||
import { isSchemaAware } from "@/lib/databaseCapabilities";
|
||||
import ExplainPlanViewer from "@/components/explain/ExplainPlanViewer.vue";
|
||||
import { parseExplainResult, type ParsedExplainPlan } from "@/lib/explainPlan";
|
||||
import { copyToClipboard } from "@/lib/clipboard";
|
||||
import { formatAiTableMention, parseAiTableMentions, type AiTableMention } from "@/lib/aiTableMentions";
|
||||
import { isAiPromptImeCompositionEvent, shouldSubmitAiPromptOnKeydown } from "@/lib/aiPromptKeyboard";
|
||||
@@ -57,6 +59,7 @@ const emit = defineEmits<{
|
||||
replaceSql: [sql: string];
|
||||
executeSql: [sql: string];
|
||||
requestAutoExecuteSql: [sql: string];
|
||||
openExplainPlan: [sql: string];
|
||||
close: [];
|
||||
}>();
|
||||
|
||||
@@ -283,6 +286,25 @@ function extractToolResultContent(result: unknown): string | undefined {
|
||||
return JSON.stringify(result);
|
||||
}
|
||||
|
||||
/** Extract structured explain plan data from the AgentEvent result value */
|
||||
function extractExplainData(result: unknown): unknown | undefined {
|
||||
if (!result || typeof result !== "object") return undefined;
|
||||
const obj = result as Record<string, unknown>;
|
||||
return obj.explain_data;
|
||||
}
|
||||
|
||||
/** Parse explain_data (a serialized QueryResult) into ParsedExplainPlan */
|
||||
function parseExplainFromData(explainData: unknown, dbType: string): ParsedExplainPlan | undefined {
|
||||
if (!explainData || typeof explainData !== "object") return undefined;
|
||||
const supportedTypes = ["mysql", "postgres", "dameng", "questdb"] as const;
|
||||
if (!supportedTypes.includes(dbType as (typeof supportedTypes)[number])) return undefined;
|
||||
try {
|
||||
return parseExplainResult(dbType as (typeof supportedTypes)[number], explainData as import("@/types/database").QueryResult);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleReasoning(index: number) {
|
||||
const next = new Set(expandedReasoning.value);
|
||||
if (next.has(index)) {
|
||||
@@ -543,20 +565,19 @@ async function send() {
|
||||
if (event.type === "tool_call_start" || event.type === "tool_call_end") {
|
||||
const msg = messages.value[assistantIdx];
|
||||
if (msg) {
|
||||
const steps = agentEvents
|
||||
.filter((e) => e.type === "tool_call_start" || e.type === "tool_call_end")
|
||||
.map((e) => ({
|
||||
key: `${e.tool_call_id || ""}-${e.type}`,
|
||||
labelKey: e.type === "tool_call_start" ? "ai.agentSteps.callingTool" : e.is_error ? "ai.agentSteps.toolError" : "ai.agentSteps.toolDone",
|
||||
tone: (e.type === "tool_call_start" ? "active" : e.is_error ? "danger" : "success") as AiAgentStepTone,
|
||||
titleKey: undefined,
|
||||
titleParams: { tool: e.tool_name || "" },
|
||||
toolName: e.tool_name,
|
||||
toolArgs: e.type === "tool_call_start" ? (e.args as Record<string, unknown>) : undefined,
|
||||
toolResult: e.type === "tool_call_end" && !e.is_error ? extractToolResultContent(e.result) : undefined,
|
||||
isError: e.type === "tool_call_end" ? e.is_error : undefined,
|
||||
}));
|
||||
msg.agentSteps = steps;
|
||||
if (!msg.agentSteps) msg.agentSteps = [];
|
||||
msg.agentSteps.push({
|
||||
key: `${event.tool_call_id || ""}-${event.type}`,
|
||||
labelKey: event.type === "tool_call_start" ? "ai.agentSteps.callingTool" : event.is_error ? "ai.agentSteps.toolError" : "ai.agentSteps.toolDone",
|
||||
tone: (event.type === "tool_call_start" ? "active" : event.is_error ? "danger" : "success") as AiAgentStepTone,
|
||||
titleKey: undefined,
|
||||
titleParams: { tool: event.tool_name || "" },
|
||||
toolName: event.tool_name,
|
||||
toolArgs: event.type === "tool_call_start" ? (event.args as Record<string, unknown>) : undefined,
|
||||
toolResult: event.type === "tool_call_end" && !event.is_error ? extractToolResultContent(event.result) : undefined,
|
||||
isError: event.type === "tool_call_end" ? event.is_error : undefined,
|
||||
explainData: event.type === "tool_call_end" ? extractExplainData(event.result) : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
scrollToBottom();
|
||||
@@ -571,7 +592,7 @@ async function send() {
|
||||
if (msg) msg.isThinking = false;
|
||||
isGenerating.value = false;
|
||||
// Render agent tool call steps from agent events
|
||||
if (msg && agentEvents.length > 0) {
|
||||
if (msg && agentEvents.length > 0 && !msg.agentSteps?.length) {
|
||||
msg.agentSteps = agentEvents
|
||||
.filter((e) => e.type === "tool_call_start" || e.type === "tool_call_end")
|
||||
.map((e) => ({
|
||||
@@ -584,6 +605,7 @@ async function send() {
|
||||
toolArgs: e.type === "tool_call_start" ? (e.args as Record<string, unknown>) : undefined,
|
||||
toolResult: e.type === "tool_call_end" && !e.is_error ? extractToolResultContent(e.result) : undefined,
|
||||
isError: e.type === "tool_call_end" ? e.is_error : undefined,
|
||||
explainData: e.type === "tool_call_end" ? extractExplainData(e.result) : undefined,
|
||||
}));
|
||||
}
|
||||
// Fallback: use aiAgentPlan for backward compatibility
|
||||
@@ -869,7 +891,14 @@ const messageRenderer = computed(() => {
|
||||
</button>
|
||||
<div v-if="expandedSteps.has(step.key)" class="border-t border-current/10 px-2 pb-2 pt-1">
|
||||
<div v-if="step.toolArgs?.sql" class="mb-1 rounded bg-background/50 px-2 py-1 font-mono text-[10px] text-foreground/80 whitespace-pre-wrap">{{ step.toolArgs.sql }}</div>
|
||||
<div v-if="step.isError && step.toolResult" class="text-[10px] text-red-600 dark:text-red-400">{{ step.toolResult }}</div>
|
||||
<Button v-if="step.toolName === 'explain_query' && step.toolArgs?.sql" size="sm" variant="outline" class="mb-1 h-6 gap-1 text-[10px]" @click="emit('openExplainPlan', step.toolArgs.sql as string)">
|
||||
<GitBranch class="h-3 w-3" />
|
||||
{{ t("explain.title") }}
|
||||
</Button>
|
||||
<div v-if="step.toolName === 'explain_query' && step.explainData && connection?.db_type" class="mb-1">
|
||||
<ExplainPlanViewer :plan="parseExplainFromData(step.explainData, connection.db_type)" class="max-h-64" />
|
||||
</div>
|
||||
<div v-else-if="step.isError && step.toolResult" class="text-[10px] text-red-600 dark:text-red-400">{{ step.toolResult }}</div>
|
||||
<div v-else-if="step.toolResult" class="max-h-48 overflow-auto text-[10px] text-muted-foreground whitespace-pre-wrap">{{ step.toolResult }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface AiAgentStepItem {
|
||||
toolResult?: string;
|
||||
/** Whether this is an error result */
|
||||
isError?: boolean;
|
||||
/** Structured explain plan data (for explain_query tool results) */
|
||||
explainData?: unknown;
|
||||
}
|
||||
|
||||
export function buildAiAgentStepItems(plan: AiAgentPlan): AiAgentStepItem[] {
|
||||
|
||||
@@ -37,6 +37,10 @@ pub struct ToolResult {
|
||||
pub tool_name: String,
|
||||
pub content: String,
|
||||
pub is_error: bool,
|
||||
/// Structured explain data (QueryResult) for explain_query tool results.
|
||||
/// Set only by execute_explain_query; None for all other tools.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub explain_data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Definition of a tool available to the agent.
|
||||
@@ -46,6 +50,9 @@ pub struct ToolDefinition {
|
||||
pub description: &'static str,
|
||||
pub parameters: serde_json::Value,
|
||||
pub read_only: bool,
|
||||
/// Whether this tool can be executed in parallel with other tools.
|
||||
/// false = must run sequentially (e.g. execute_query).
|
||||
pub parallel_ok: bool,
|
||||
}
|
||||
|
||||
impl ToolDefinition {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures::future::join_all;
|
||||
use futures::FutureExt;
|
||||
use serde_json::json;
|
||||
use tokio::sync::Notify;
|
||||
|
||||
use crate::agent_events::{AgentEvent, ToolCall, ToolDefinition};
|
||||
use crate::agent_events::{AgentEvent, ToolCall, ToolDefinition, ToolResult};
|
||||
use crate::agent_tools;
|
||||
use crate::ai::{self, AiCompletionRequest, AiConfig, AiMessage, AiProvider, AiStreamChunk};
|
||||
use crate::connection::AppState;
|
||||
@@ -65,7 +66,7 @@ pub async fn run_agent_loop(
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let tools = if is_agent_mode { agent_tools::all_tools() } else { agent_tools::read_only_tools() };
|
||||
let tools = if is_agent_mode { agent_tools::all_tools(agent_ctx.db_type) } else { agent_tools::read_only_tools() };
|
||||
let mut conversation_messages: Vec<AiMessage> = messages.to_vec();
|
||||
let mut final_text = String::new();
|
||||
|
||||
@@ -126,32 +127,71 @@ pub async fn run_agent_loop(
|
||||
}
|
||||
|
||||
// Execute each tool call
|
||||
// Emit all ToolCallStart events first
|
||||
for tc in &collected_tool_calls {
|
||||
on_event(AgentEvent::ToolCallStart {
|
||||
tool_call_id: tc.id.clone(),
|
||||
tool_name: tc.name.clone(),
|
||||
args: tc.arguments.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
let result = agent_tools::execute_tool(
|
||||
tc,
|
||||
&agent_ctx.state,
|
||||
&agent_ctx.connection_id,
|
||||
&agent_ctx.database,
|
||||
&agent_ctx.db_type,
|
||||
)
|
||||
.await;
|
||||
// Execute tool calls: parallel for read tools, sequential for execute_query
|
||||
let state2 = Arc::clone(&agent_ctx.state);
|
||||
let conn2 = agent_ctx.connection_id.clone();
|
||||
let db2 = agent_ctx.database.clone();
|
||||
let db_type = agent_ctx.db_type;
|
||||
|
||||
// Split by index into parallel and sequential groups using tool metadata
|
||||
let tool_parallel_map: std::collections::HashMap<&str, bool> =
|
||||
tools.iter().map(|t| (t.name, t.parallel_ok)).collect();
|
||||
let (parallel_indices, sequential_indices): (Vec<usize>, Vec<usize>) = (0..collected_tool_calls.len())
|
||||
.partition(|&i| *tool_parallel_map.get(collected_tool_calls[i].name.as_str()).unwrap_or(&false));
|
||||
|
||||
let make_tc =
|
||||
|tc: &ToolCall| ToolCall { id: tc.id.clone(), name: tc.name.clone(), arguments: tc.arguments.clone() };
|
||||
|
||||
// Run parallel group
|
||||
let parallel_futures: Vec<_> = parallel_indices
|
||||
.iter()
|
||||
.map(|&i| {
|
||||
let tc = make_tc(&collected_tool_calls[i]);
|
||||
let state = Arc::clone(&state2);
|
||||
let conn = conn2.clone();
|
||||
let db = db2.clone();
|
||||
async move { agent_tools::execute_tool(&tc, &state, &conn, &db, &db_type).await }
|
||||
})
|
||||
.collect();
|
||||
let parallel_results = join_all(parallel_futures).await;
|
||||
|
||||
// Run sequential group one-by-one
|
||||
let mut sequential_results = Vec::with_capacity(sequential_indices.len());
|
||||
for &i in &sequential_indices {
|
||||
let tc = make_tc(&collected_tool_calls[i]);
|
||||
sequential_results.push(agent_tools::execute_tool(&tc, &state2, &conn2, &db2, &db_type).await);
|
||||
}
|
||||
|
||||
// Merge results back into original order
|
||||
let mut results: Vec<Option<ToolResult>> = vec![None; collected_tool_calls.len()];
|
||||
for (pos, &i) in parallel_indices.iter().enumerate() {
|
||||
results[i] = Some(parallel_results[pos].clone());
|
||||
}
|
||||
for (pos, &i) in sequential_indices.iter().enumerate() {
|
||||
results[i] = Some(sequential_results[pos].clone());
|
||||
}
|
||||
let results: Vec<ToolResult> = results.into_iter().map(|r| r.unwrap()).collect();
|
||||
|
||||
// Process results in order, emitting ToolCallEnd events
|
||||
for (tc, result) in collected_tool_calls.iter().zip(results) {
|
||||
on_event(AgentEvent::ToolCallEnd {
|
||||
tool_call_id: tc.id.clone(),
|
||||
tool_name: tc.name.clone(),
|
||||
result: json!({ "content": result.content }),
|
||||
result: match &result.explain_data {
|
||||
Some(ed) => json!({ "content": result.content, "explain_data": ed }),
|
||||
None => json!({ "content": result.content }),
|
||||
},
|
||||
is_error: result.is_error,
|
||||
});
|
||||
|
||||
// Add tool result to conversation for the next LLM call
|
||||
// Uses "tool" role per OpenAI convention; provider-specific conversion
|
||||
// happens in ai::stream_with_tools when building provider API requests.
|
||||
conversation_messages.push(AiMessage {
|
||||
role: "tool".to_string(),
|
||||
content: result.content.clone(),
|
||||
@@ -260,14 +300,14 @@ async fn build_schema_prompt(agent_ctx: &AgentLoopContext, system_prompt: &str)
|
||||
|
||||
match tables_result {
|
||||
Ok(tables) if !tables.is_empty() => {
|
||||
enriched.push_str("\n\n## Database Schema (for context 鈥?no tools available)\n");
|
||||
enriched.push_str("\n\n## Database Schema (for context — no tools available)\n");
|
||||
enriched.push_str(&format!("Database: {}\n", agent_ctx.database));
|
||||
enriched.push_str("Tables:\n");
|
||||
for t in &tables {
|
||||
enriched.push_str(&format!(" - {} ({})", t.name, t.table_type));
|
||||
if let Some(ref comment) = t.comment {
|
||||
if !comment.trim().is_empty() {
|
||||
enriched.push_str(&format!(" 鈥?{}", comment.trim()));
|
||||
enriched.push_str(&format!(" — {}", comment.trim()));
|
||||
}
|
||||
}
|
||||
enriched.push('\n');
|
||||
|
||||
@@ -6,6 +6,7 @@ use crate::agent_events::{ToolCall, ToolDefinition, ToolResult};
|
||||
use crate::connection::AppState;
|
||||
use crate::models::connection::DatabaseType;
|
||||
use crate::query::QueryExecutionOptions;
|
||||
use crate::query_execution_sql::{build_explain_sql, supports_explain_plan, supports_sql_query, ExplainSqlOptions};
|
||||
use crate::sql_risk::SqlRisk;
|
||||
use crate::types::QueryResult;
|
||||
|
||||
@@ -21,14 +22,24 @@ const SAMPLE_DATA_LIMIT: usize = 20;
|
||||
/// Absolute maximum rows any query tool may request.
|
||||
const MAX_ALLOWED_ROWS: usize = 100;
|
||||
|
||||
/// Get all available tool definitions for Phase 1 (read-only tools).
|
||||
/// Get read-only tool definitions (list_tables + get_columns).
|
||||
pub fn read_only_tools() -> Vec<ToolDefinition> {
|
||||
vec![list_tables_tool(), get_columns_tool()]
|
||||
}
|
||||
|
||||
/// Get all available tool definitions (Phase 2: read-only + execute_query + get_sample_data).
|
||||
pub fn all_tools() -> Vec<ToolDefinition> {
|
||||
vec![list_tables_tool(), get_columns_tool(), execute_query_tool(), get_sample_data_tool()]
|
||||
/// Get all available tool definitions for the given database type.
|
||||
/// Includes read-only tools plus execute_query, get_sample_data, and
|
||||
/// explain_query for database types that support them.
|
||||
pub fn all_tools(db_type: DatabaseType) -> Vec<ToolDefinition> {
|
||||
let mut tools = vec![list_tables_tool(), get_columns_tool()];
|
||||
if supports_sql_query(db_type) {
|
||||
tools.push(execute_query_tool());
|
||||
tools.push(get_sample_data_tool());
|
||||
}
|
||||
if supports_explain_plan(Some(db_type)) {
|
||||
tools.push(explain_query_tool());
|
||||
}
|
||||
tools
|
||||
}
|
||||
|
||||
/// list_tables tool definition.
|
||||
@@ -47,6 +58,7 @@ fn list_tables_tool() -> ToolDefinition {
|
||||
"required": []
|
||||
}),
|
||||
read_only: true,
|
||||
parallel_ok: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,9 +85,10 @@ fn get_columns_tool() -> ToolDefinition {
|
||||
"required": ["table"]
|
||||
}),
|
||||
read_only: true,
|
||||
parallel_ok: true,
|
||||
}
|
||||
}
|
||||
/// execute_query tool definition (Phase 2).
|
||||
/// execute_query tool definition.
|
||||
fn execute_query_tool() -> ToolDefinition {
|
||||
ToolDefinition {
|
||||
name: "execute_query",
|
||||
@@ -97,10 +110,11 @@ fn execute_query_tool() -> ToolDefinition {
|
||||
"required": ["sql"]
|
||||
}),
|
||||
read_only: true,
|
||||
parallel_ok: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// get_sample_data tool definition (Phase 2).
|
||||
/// get_sample_data tool definition.
|
||||
fn get_sample_data_tool() -> ToolDefinition {
|
||||
ToolDefinition {
|
||||
name: "get_sample_data",
|
||||
@@ -124,6 +138,30 @@ fn get_sample_data_tool() -> ToolDefinition {
|
||||
"required": ["table"]
|
||||
}),
|
||||
read_only: true,
|
||||
parallel_ok: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// explain_query tool definition (Phase 3).
|
||||
fn explain_query_tool() -> ToolDefinition {
|
||||
ToolDefinition {
|
||||
name: "explain_query",
|
||||
description: "Get the execution plan for a SQL query using EXPLAIN. \
|
||||
Shows how the database will execute the query (scan type, indexes, cost). \
|
||||
Only read-only queries (SELECT, WITH, SHOW, DESCRIBE, EXPLAIN) are allowed. \
|
||||
Use this to analyze query performance and suggest index optimizations.",
|
||||
parameters: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sql": {
|
||||
"type": "string",
|
||||
"description": "The SQL query to explain (must be read-only)"
|
||||
}
|
||||
},
|
||||
"required": ["sql"]
|
||||
}),
|
||||
read_only: true,
|
||||
parallel_ok: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,6 +178,30 @@ pub async fn execute_tool(
|
||||
"get_columns" => execute_get_columns(tool_call, state, connection_id, database, db_type).await,
|
||||
"execute_query" => execute_execute_query(tool_call, state, connection_id, database, db_type).await,
|
||||
"get_sample_data" => execute_get_sample_data(tool_call, state, connection_id, database, db_type).await,
|
||||
"explain_query" => {
|
||||
let (text_result, explain_data) =
|
||||
execute_explain_query(tool_call, state, connection_id, database, db_type).await;
|
||||
match text_result {
|
||||
Ok(content) => {
|
||||
return ToolResult {
|
||||
tool_call_id: tool_call.id.clone(),
|
||||
tool_name: tool_call.name.clone(),
|
||||
content,
|
||||
is_error: false,
|
||||
explain_data,
|
||||
};
|
||||
}
|
||||
Err(err) => {
|
||||
return ToolResult {
|
||||
tool_call_id: tool_call.id.clone(),
|
||||
tool_name: tool_call.name.clone(),
|
||||
content: format!("Error: {err}"),
|
||||
is_error: true,
|
||||
explain_data: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => Err(format!("Unknown tool: {}", tool_call.name)),
|
||||
};
|
||||
|
||||
@@ -149,12 +211,14 @@ pub async fn execute_tool(
|
||||
tool_name: tool_call.name.clone(),
|
||||
content,
|
||||
is_error: false,
|
||||
explain_data: None,
|
||||
},
|
||||
Err(err) => ToolResult {
|
||||
tool_call_id: tool_call.id.clone(),
|
||||
tool_name: tool_call.name.clone(),
|
||||
content: format!("Error: {err}"),
|
||||
is_error: true,
|
||||
explain_data: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -409,3 +473,79 @@ async fn execute_get_sample_data(
|
||||
};
|
||||
execute_execute_query(&synthetic_call, state, connection_id, database, db_type).await
|
||||
}
|
||||
|
||||
/// Execute an EXPLAIN query via the explain_query tool.
|
||||
/// Returns (text_for_llm, optional_explain_data_for_frontend).
|
||||
async fn execute_explain_query(
|
||||
tool_call: &ToolCall,
|
||||
state: &Arc<AppState>,
|
||||
connection_id: &str,
|
||||
database: &str,
|
||||
db_type: &DatabaseType,
|
||||
) -> (Result<String, String>, Option<serde_json::Value>) {
|
||||
let sql = match tool_call.arguments.get("sql").and_then(|v| v.as_str()) {
|
||||
Some(s) => s.trim(),
|
||||
None => return (Err("Missing required parameter: sql".to_string()), None),
|
||||
};
|
||||
|
||||
if sql.is_empty() {
|
||||
return (Err("SQL query cannot be empty".to_string()), None);
|
||||
}
|
||||
|
||||
// Classify SQL risk – only ReadOnly queries can be explained
|
||||
let db_type_str = format!("{:?}", db_type).to_lowercase();
|
||||
let risk = match crate::sql_risk::classify_sql_risk(sql, &db_type_str) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return (Err(e), None),
|
||||
};
|
||||
match risk {
|
||||
SqlRisk::ReadOnly => { /* proceed */ }
|
||||
_ => {
|
||||
return (
|
||||
Err(format!(
|
||||
"Blocked: {} statement detected. Only read-only queries (SELECT, SHOW, DESCRIBE, EXPLAIN) can be analyzed.",
|
||||
risk
|
||||
)),
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Build the database-specific EXPLAIN SQL
|
||||
let explain_result = build_explain_sql(ExplainSqlOptions { database_type: Some(*db_type), sql: sql.to_string() });
|
||||
|
||||
let explain_sql = match (explain_result.ok, explain_result.sql) {
|
||||
(true, Some(sql)) => sql,
|
||||
(true, None) => return (Err("EXPLAIN SQL is empty".to_string()), None),
|
||||
(false, _) => {
|
||||
let reason = explain_result.reason.unwrap_or_else(|| "unknown".to_string());
|
||||
return (Err(format!("Cannot explain this query: {}. The database type may not support EXPLAIN, or the query may be unsafe.", reason)), None);
|
||||
}
|
||||
};
|
||||
|
||||
// Execute the EXPLAIN query
|
||||
let options = QueryExecutionOptions { max_rows: Some(100), timeout_secs: Some(30), ..Default::default() };
|
||||
let result = match crate::query::execute_sql_statement_with_options(
|
||||
state,
|
||||
connection_id,
|
||||
database,
|
||||
&explain_sql,
|
||||
None,
|
||||
None,
|
||||
options,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => return (Err(e), None),
|
||||
};
|
||||
|
||||
// Serialize the raw QueryResult for the frontend ExplainPlanViewer
|
||||
let explain_data = serde_json::to_value(&result).ok();
|
||||
let text = match format_query_result_as_text(&result, 100) {
|
||||
Ok(t) => t,
|
||||
Err(e) => return (Err(e), None),
|
||||
};
|
||||
|
||||
(Ok(text), explain_data)
|
||||
}
|
||||
|
||||
@@ -79,6 +79,20 @@ pub fn supports_explain_plan(database_type: Option<DatabaseType>) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns true for databases that support SQL query execution (execute_query / get_sample_data).
|
||||
/// Non-SQL databases (Redis, MongoDB, Elasticsearch, InfluxDB, Neo4j, etcd) are excluded.
|
||||
pub fn supports_sql_query(database_type: DatabaseType) -> bool {
|
||||
!matches!(
|
||||
database_type,
|
||||
DatabaseType::Redis
|
||||
| DatabaseType::MongoDb
|
||||
| DatabaseType::Elasticsearch
|
||||
| DatabaseType::InfluxDb
|
||||
| DatabaseType::Neo4j
|
||||
| DatabaseType::Etcd
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_safe_dameng_autotrace_sql(sql: &str) -> bool {
|
||||
let source = strip_trailing_semicolons(sql.trim());
|
||||
if source.is_empty() || has_extra_statement_after_semicolon(&source) {
|
||||
|
||||
Reference in New Issue
Block a user