use std::sync::Arc;
use anyhow::Result;
use async_trait::async_trait;
use serde_json::{json, Value};
use crate::capabilities::file_system::FileSystem;
use crate::core::tool::{Tool, ToolContext, ToolResult};
use crate::sql::is_read_only;
use crate::sqlrunner::{QueryResult, SqlRunner};
const PREVIEW_LIMIT: usize = 1000;
const DISPLAY_LIMIT: usize = 1000;
pub struct RunSqlTool {
runner: Arc<dyn SqlRunner>,
fs: Arc<dyn FileSystem>,
}
impl RunSqlTool {
pub fn new(runner: Arc<dyn SqlRunner>, fs: Arc<dyn FileSystem>) -> Self {
Self { runner, fs }
}
}
fn to_csv(r: &QueryResult) -> String {
let mut out = r.columns.join(",");
out.push('\n');
for row in &r.rows {
out.push_str(&row.join(","));
out.push('\n');
}
out
}
#[async_trait]
impl Tool for RunSqlTool {
fn name(&self) -> &str {
"run_sql"
}
fn description(&self) -> &str {
"Execute a read-only SQL query against the connected database and return the rows. \
Use this to actually answer the user's data questions — do not just describe SQL."
}
fn args_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"sql": {
"type": "string",
"description": "The SQL query to execute (dialect of the connected database)."
}
},
"required": ["sql"]
})
}
async fn execute(&self, _ctx: &ToolContext, args: Value) -> Result<ToolResult> {
let sql = args["sql"].as_str().unwrap_or("").trim().to_string();
if sql.is_empty() {
return Ok(ToolResult::error("No SQL provided."));
}
if !is_read_only(&sql) {
return Ok(ToolResult::error(
"Blocked: only read-only queries (SELECT / WITH) are allowed. \
This tool cannot modify or delete data.",
));
}
let result = match self.runner.run_sql(&sql).await {
Ok(r) => r,
Err(e) => return Ok(ToolResult::error(format!("SQL error: {e}"))),
};
if result.rows.is_empty() {
let ui = json!({
"kind": "dataframe",
"columns": result.columns,
"rows": [],
"title": "Query Results",
"description": "No rows returned"
});
return Ok(ToolResult::ok("Query executed successfully. No rows returned.").with_ui(ui));
}
let id = format!("{:08x}", rand::random::<u32>());
let filename = format!("query_results_{id}.json");
let structured = json!({ "columns": result.columns, "rows": result.rows });
self.fs.write_file(&filename, &structured.to_string()).await?;
let mut preview = to_csv(&result);
if preview.len() > PREVIEW_LIMIT {
preview.truncate(PREVIEW_LIMIT);
preview.push_str(
"\n(Results truncated. For large result sets do NOT summarize them — \
call visualize_data next.)",
);
}
let result_for_llm = format!(
"{preview}\n\nResults saved to file: {filename}\n\n\
**IMPORTANT: FOR VISUALIZE_DATA USE FILENAME: {filename}**"
);
let total_rows = result.rows.len();
let display_rows: Vec<Vec<String>> = result.rows.iter().take(DISPLAY_LIMIT).cloned().collect();
let description = if total_rows > DISPLAY_LIMIT {
format!("Showing first {DISPLAY_LIMIT} of {total_rows} rows, {} columns", result.columns.len())
} else {
format!("{total_rows} row(s), {} column(s)", result.columns.len())
};
let ui = json!({
"kind": "dataframe",
"columns": result.columns,
"rows": display_rows,
"title": "Query Results",
"description": description
});
Ok(ToolResult::ok(result_for_llm).with_ui(ui))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::capabilities::file_system::MemoryFileSystem;
use crate::sqlrunner::sqlite::SqliteRunner;
async fn temp_db() -> (Arc<SqliteRunner>, std::path::PathBuf) {
let path = std::env::temp_dir().join(format!("opendbpylot_runsql_{}.db", rand::random::<u32>()));
let db = SqliteRunner::new(path.to_string_lossy().to_string());
db.run_sql("CREATE TABLE users (id INTEGER, country TEXT)").await.unwrap();
db.run_sql("INSERT INTO users VALUES (1,'USA'),(2,'USA'),(3,'UK')").await.unwrap();
(Arc::new(db), path)
}
#[tokio::test]
async fn select_returns_rows_and_stashes_a_file() {
let (db, path) = temp_db().await;
let fs: Arc<dyn FileSystem> = Arc::new(MemoryFileSystem::new());
let tool = RunSqlTool::new(db, fs.clone());
let res = tool
.execute(
&ToolContext::default(),
json!({ "sql": "SELECT country, COUNT(*) AS n FROM users GROUP BY country ORDER BY n DESC" }),
)
.await
.unwrap();
assert!(res.success);
assert!(res.result_for_llm.contains("USA"));
assert!(res.result_for_llm.contains("query_results_"));
let ui = res.ui.expect("expected a dataframe ui payload");
assert_eq!(ui["kind"], "dataframe");
assert_eq!(ui["rows"].as_array().unwrap().len(), 2);
let fname = ui_filename(&res.result_for_llm);
let stored = fs.read_file(&fname).await.unwrap();
assert!(stored.contains("country"));
let _ = std::fs::remove_file(&path);
}
#[tokio::test]
async fn bad_sql_is_reported_not_panicked() {
let (db, path) = temp_db().await;
let fs: Arc<dyn FileSystem> = Arc::new(MemoryFileSystem::new());
let tool = RunSqlTool::new(db, fs);
let res = tool
.execute(&ToolContext::default(), json!({ "sql": "SELECT * FROM nope" }))
.await
.unwrap();
assert!(!res.success);
assert!(res.result_for_llm.to_lowercase().contains("error"));
let _ = std::fs::remove_file(&path);
}
#[tokio::test]
async fn destructive_sql_is_blocked_and_data_survives() {
let (db, path) = temp_db().await;
let fs: Arc<dyn FileSystem> = Arc::new(MemoryFileSystem::new());
let tool = RunSqlTool::new(db.clone(), fs);
let res = tool
.execute(&ToolContext::default(), json!({ "sql": "DROP TABLE users" }))
.await
.unwrap();
assert!(!res.success);
assert!(res.result_for_llm.to_lowercase().contains("read-only"));
let check = db.run_sql("SELECT COUNT(*) FROM users").await.unwrap();
assert_eq!(check.rows[0][0], "3");
let _ = std::fs::remove_file(&path);
}
fn ui_filename(s: &str) -> String {
s.split_whitespace()
.find(|w| w.starts_with("query_results_"))
.unwrap_or("")
.trim_end_matches("**")
.to_string()
}
#[tokio::test]
async fn agent_loop_runs_real_sql_end_to_end() {
use crate::core::agent::{Agent, AgentEvent};
use crate::core::registry::ToolRegistry;
use crate::core::tool::ToolContext;
use crate::llm::mock::ScriptedToolLlm;
use crate::llm::{LlmResponse, ToolCall};
use tokio_stream::StreamExt;
let (db, path) = temp_db().await;
let fs: Arc<dyn FileSystem> = Arc::new(MemoryFileSystem::new());
let mut registry = ToolRegistry::new();
registry.register(Arc::new(RunSqlTool::new(db, fs)));
let llm = Arc::new(ScriptedToolLlm::new(vec![
LlmResponse {
text: None,
tool_calls: vec![ToolCall {
id: "c1".into(),
name: "run_sql".into(),
args: json!({ "sql": "SELECT country, COUNT(*) AS n FROM users GROUP BY country ORDER BY n DESC" }),
}],
},
LlmResponse::text("The USA has the most users (2)."),
]));
let agent = Agent::new(llm, Arc::new(registry));
let stream = agent.send_message(ToolContext::default(), "which country has the most users?".into());
tokio::pin!(stream);
let mut events = Vec::new();
while let Some(ev) = stream.next().await {
events.push(ev);
}
let finished = events.iter().find_map(|e| match e {
AgentEvent::ToolFinished { name, success, result, ui } if name == "run_sql" => {
Some((*success, result.clone(), ui.clone()))
}
_ => None,
});
let (success, result, ui) = finished.expect("expected run_sql to finish");
assert!(success);
assert!(result.contains("USA"));
assert_eq!(ui.unwrap()["rows"].as_array().unwrap().len(), 2);
assert!(matches!(events.last(), Some(AgentEvent::FinalText(t)) if t.contains("USA")));
let _ = std::fs::remove_file(&path);
}
}