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