diff --git a/.gitignore b/.gitignore index 74638440c5..5de0d42efa 100644 --- a/.gitignore +++ b/.gitignore @@ -60,4 +60,5 @@ releases-*.json test.pdb portable/ DBX_*_x64-portable.zip -.agents/skills \ No newline at end of file +.agents/skills +.pnpm-store/ \ No newline at end of file diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 7fd34f326d..8558023ac4 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -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(() => {
- +
diff --git a/apps/desktop/src/components/editor/AiAssistant.vue b/apps/desktop/src/components/editor/AiAssistant.vue index 08d0cc3b14..c7f77a0f16 100644 --- a/apps/desktop/src/components/editor/AiAssistant.vue +++ b/apps/desktop/src/components/editor/AiAssistant.vue @@ -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; + 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) : 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) : 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) : 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(() => {
{{ step.toolArgs.sql }}
-
{{ step.toolResult }}
+ +
+ +
+
{{ step.toolResult }}
{{ step.toolResult }}
diff --git a/apps/desktop/src/lib/aiAgentStepPresentation.ts b/apps/desktop/src/lib/aiAgentStepPresentation.ts index 13af727184..9ce7d44d76 100644 --- a/apps/desktop/src/lib/aiAgentStepPresentation.ts +++ b/apps/desktop/src/lib/aiAgentStepPresentation.ts @@ -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[] { diff --git a/crates/dbx-core/src/agent_events.rs b/crates/dbx-core/src/agent_events.rs index 91ea8526c6..a3dc02a4c9 100644 --- a/crates/dbx-core/src/agent_events.rs +++ b/crates/dbx-core/src/agent_events.rs @@ -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, } /// 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 { diff --git a/crates/dbx-core/src/agent_loop.rs b/crates/dbx-core/src/agent_loop.rs index 74abda29e3..0ee93bb1a5 100644 --- a/crates/dbx-core/src/agent_loop.rs +++ b/crates/dbx-core/src/agent_loop.rs @@ -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 = 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, Vec) = (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> = 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 = 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'); diff --git a/crates/dbx-core/src/agent_tools.rs b/crates/dbx-core/src/agent_tools.rs index edbd730527..6e72462a7a 100644 --- a/crates/dbx-core/src/agent_tools.rs +++ b/crates/dbx-core/src/agent_tools.rs @@ -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 { 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 { - 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 { + 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, + connection_id: &str, + database: &str, + db_type: &DatabaseType, +) -> (Result, Option) { + 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) +} diff --git a/crates/dbx-core/src/query_execution_sql.rs b/crates/dbx-core/src/query_execution_sql.rs index e5bc71ff9b..b968fd828d 100644 --- a/crates/dbx-core/src/query_execution_sql.rs +++ b/crates/dbx-core/src/query_execution_sql.rs @@ -79,6 +79,20 @@ pub fn supports_explain_plan(database_type: Option) -> 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) {