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:
Abeautifulsnow
2026-06-15 13:14:59 +08:00
committed by GitHub
parent a3c972c367
commit d845a318c6
8 changed files with 284 additions and 42 deletions
+2 -1
View File
@@ -60,4 +60,5 @@ releases-*.json
test.pdb
portable/
DBX_*_x64-portable.zip
.agents/skills
.agents/skills
.pnpm-store/
+10 -1
View File
@@ -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[] {
+7
View File
@@ -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 {
+57 -17
View File
@@ -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');
+146 -6
View File
@@ -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) {