Skip to main content

everruns_core/capabilities/
session_sql_database.rs

1// Session SQL Database Capability
2//
3// Provides session-scoped SQLite databases via three tools:
4// - sql_execute: DDL/DML (CREATE TABLE, INSERT, UPDATE, DELETE). Auto-creates DB.
5// - sql_query: Read-only SELECT queries returning columns + rows as JSON.
6// - sql_schema: Introspect database schema (tables, columns, types).
7
8use super::{Capability, CapabilityStatus};
9use crate::session_sqldb::SessionSqlDbError;
10use crate::tool_types::ToolHints;
11use crate::tools::{Tool, ToolExecutionResult};
12use crate::traits::ToolContext;
13use crate::truncation_info::{TruncationInfo, TruncationReason};
14use async_trait::async_trait;
15use serde_json::{Value, json};
16
17/// Session SQL Database capability
18pub struct SessionSqlDatabaseCapability;
19
20impl Capability for SessionSqlDatabaseCapability {
21    fn id(&self) -> &str {
22        "session_sql_database"
23    }
24
25    fn name(&self) -> &str {
26        "SQL Database"
27    }
28
29    fn description(&self) -> &str {
30        "Session-scoped SQLite databases for structured data storage and querying."
31    }
32
33    fn status(&self) -> CapabilityStatus {
34        CapabilityStatus::Available
35    }
36
37    fn icon(&self) -> Option<&str> {
38        Some("database")
39    }
40
41    fn category(&self) -> Option<&str> {
42        Some("Data")
43    }
44
45    fn system_prompt_addition(&self) -> Option<&str> {
46        Some(
47            r#"Database names must be alphanumeric with underscores. Results limited to 1000 rows per query. Standard SQLite SQL syntax."#,
48        )
49    }
50
51    fn tools(&self) -> Vec<Box<dyn Tool>> {
52        vec![
53            Box::new(SqlExecuteTool),
54            Box::new(SqlQueryTool),
55            Box::new(SqlSchemaTool),
56        ]
57    }
58
59    fn features(&self) -> Vec<&'static str> {
60        vec!["sql_database"]
61    }
62}
63
64// ============================================================================
65// Helper: convert SessionSqlDbError to ToolExecutionResult
66// ============================================================================
67
68fn sqldb_error_to_result(err: SessionSqlDbError) -> ToolExecutionResult {
69    if err.is_tool_error() {
70        ToolExecutionResult::tool_error(err.to_string())
71    } else {
72        ToolExecutionResult::internal_error_msg(err.to_string())
73    }
74}
75
76/// Shape a `sql_query` response with the unified reading-tool truncation
77/// envelope (EVE-339).
78///
79/// `sql_query` does not support in-place offset resume: if the result set
80/// exceeds the row/byte cap, the caller must paginate via `LIMIT`/`OFFSET`
81/// or narrow the `WHERE`. That fallback is documented in
82/// `specs/session-sqldb.md`. The envelope therefore uses `without_resume`
83/// when truncated.
84fn shape_sql_query_response(
85    database: &str,
86    columns: &[String],
87    rows: &[Vec<Value>],
88    row_count: usize,
89    truncated: bool,
90) -> Value {
91    let mut response = json!({
92        "database": database,
93        "columns": columns,
94        "rows": rows,
95        "row_count": row_count
96    });
97    if truncated {
98        response["truncated"] = json!(true);
99    }
100    // `bytes_returned` measures the primary payload (`rows`), matching the
101    // reading-tool contract's "primary content" definition rather than the
102    // wrapping object.
103    let bytes_returned = serde_json::to_string(rows)
104        .expect("sql_query rows always serialize")
105        .len();
106    let info = if truncated {
107        TruncationInfo::without_resume(bytes_returned, None, TruncationReason::RowCap)
108    } else {
109        TruncationInfo::not_truncated(bytes_returned)
110    };
111    info.attach(&mut response);
112    response
113}
114
115// ============================================================================
116// SqlExecuteTool
117// ============================================================================
118
119pub struct SqlExecuteTool;
120
121#[async_trait]
122impl Tool for SqlExecuteTool {
123    fn name(&self) -> &str {
124        "sql_execute"
125    }
126
127    fn display_name(&self) -> Option<&str> {
128        Some("SQL Execute")
129    }
130
131    fn description(&self) -> &str {
132        "Execute DDL/DML SQL (CREATE TABLE, INSERT, UPDATE, DELETE). Auto-creates database if it doesn't exist."
133    }
134
135    fn parameters_schema(&self) -> Value {
136        json!({
137            "type": "object",
138            "properties": {
139                "database": {
140                    "type": "string",
141                    "description": "Database name (alphanumeric + underscores)"
142                },
143                "sql": {
144                    "type": "string",
145                    "description": "SQL statement(s) to execute"
146                }
147            },
148            "required": ["database", "sql"],
149            "additionalProperties": false
150        })
151    }
152
153    fn hints(&self) -> ToolHints {
154        // Mutates the shared session SQL database (DDL/DML): serialize
155        // concurrent sql_execute calls within a batch to avoid write races.
156        ToolHints::default().with_concurrency_class("session_sql")
157    }
158
159    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
160        ToolExecutionResult::tool_error(
161            "sql_execute requires context. This tool must be executed with session context.",
162        )
163    }
164
165    async fn execute_with_context(
166        &self,
167        arguments: Value,
168        context: &ToolContext,
169    ) -> ToolExecutionResult {
170        let database = match arguments.get("database").and_then(|v| v.as_str()) {
171            Some(d) => d,
172            None => {
173                return ToolExecutionResult::tool_error("Missing required parameter: database");
174            }
175        };
176
177        let sql = match arguments.get("sql").and_then(|v| v.as_str()) {
178            Some(s) => s,
179            None => {
180                return ToolExecutionResult::tool_error("Missing required parameter: sql");
181            }
182        };
183
184        let store = match &context.sqldb_store {
185            Some(store) => store,
186            None => {
187                return ToolExecutionResult::tool_error(
188                    "SQL database not available in this context",
189                );
190            }
191        };
192
193        match store.sql_execute(context.session_id, database, sql).await {
194            Ok(result) => ToolExecutionResult::success(json!({
195                "database": database,
196                "success": true,
197                "rows_affected": result.rows_affected
198            })),
199            Err(e) => sqldb_error_to_result(e),
200        }
201    }
202
203    fn requires_context(&self) -> bool {
204        true
205    }
206}
207
208// ============================================================================
209// SqlQueryTool
210// ============================================================================
211
212pub struct SqlQueryTool;
213
214#[async_trait]
215impl Tool for SqlQueryTool {
216    fn name(&self) -> &str {
217        "sql_query"
218    }
219
220    fn display_name(&self) -> Option<&str> {
221        Some("SQL Query")
222    }
223
224    fn description(&self) -> &str {
225        "Execute a read-only SQL query (SELECT). Returns columns and rows as JSON."
226    }
227
228    fn parameters_schema(&self) -> Value {
229        json!({
230            "type": "object",
231            "properties": {
232                "database": {
233                    "type": "string",
234                    "description": "Database name"
235                },
236                "sql": {
237                    "type": "string",
238                    "description": "SELECT query"
239                }
240            },
241            "required": ["database", "sql"],
242            "additionalProperties": false
243        })
244    }
245
246    fn hints(&self) -> ToolHints {
247        ToolHints::default().with_readonly(true)
248    }
249
250    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
251        ToolExecutionResult::tool_error(
252            "sql_query requires context. This tool must be executed with session context.",
253        )
254    }
255
256    async fn execute_with_context(
257        &self,
258        arguments: Value,
259        context: &ToolContext,
260    ) -> ToolExecutionResult {
261        let database = match arguments.get("database").and_then(|v| v.as_str()) {
262            Some(d) => d,
263            None => {
264                return ToolExecutionResult::tool_error("Missing required parameter: database");
265            }
266        };
267
268        let sql = match arguments.get("sql").and_then(|v| v.as_str()) {
269            Some(s) => s,
270            None => {
271                return ToolExecutionResult::tool_error("Missing required parameter: sql");
272            }
273        };
274
275        let store = match &context.sqldb_store {
276            Some(store) => store,
277            None => {
278                return ToolExecutionResult::tool_error(
279                    "SQL database not available in this context",
280                );
281            }
282        };
283
284        match store.sql_query(context.session_id, database, sql).await {
285            Ok(result) => {
286                let response = shape_sql_query_response(
287                    database,
288                    &result.columns,
289                    &result.rows,
290                    result.row_count,
291                    result.truncated,
292                );
293                ToolExecutionResult::success(response)
294            }
295            Err(e) => sqldb_error_to_result(e),
296        }
297    }
298
299    fn requires_context(&self) -> bool {
300        true
301    }
302}
303
304// ============================================================================
305// SqlSchemaTool
306// ============================================================================
307
308pub struct SqlSchemaTool;
309
310#[async_trait]
311impl Tool for SqlSchemaTool {
312    fn name(&self) -> &str {
313        "sql_schema"
314    }
315
316    fn display_name(&self) -> Option<&str> {
317        Some("SQL Schema")
318    }
319
320    fn description(&self) -> &str {
321        "Introspect database schema: tables, columns, types, and row counts."
322    }
323
324    fn parameters_schema(&self) -> Value {
325        json!({
326            "type": "object",
327            "properties": {
328                "database": {
329                    "type": "string",
330                    "description": "Database name"
331                },
332                "table": {
333                    "type": "string",
334                    "description": "Specific table name (optional, omit to list all tables)"
335                }
336            },
337            "required": ["database"],
338            "additionalProperties": false
339        })
340    }
341
342    fn hints(&self) -> ToolHints {
343        ToolHints::default()
344            .with_readonly(true)
345            .with_idempotent(true)
346    }
347
348    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
349        ToolExecutionResult::tool_error(
350            "sql_schema requires context. This tool must be executed with session context.",
351        )
352    }
353
354    async fn execute_with_context(
355        &self,
356        arguments: Value,
357        context: &ToolContext,
358    ) -> ToolExecutionResult {
359        let database = match arguments.get("database").and_then(|v| v.as_str()) {
360            Some(d) => d,
361            None => {
362                return ToolExecutionResult::tool_error("Missing required parameter: database");
363            }
364        };
365
366        let table = arguments.get("table").and_then(|v| v.as_str());
367
368        let store = match &context.sqldb_store {
369            Some(store) => store,
370            None => {
371                return ToolExecutionResult::tool_error(
372                    "SQL database not available in this context",
373                );
374            }
375        };
376
377        match store.sql_schema(context.session_id, database, table).await {
378            Ok(tables) => {
379                let tables_json: Vec<Value> = tables
380                    .into_iter()
381                    .map(|t| {
382                        json!({
383                            "name": t.name,
384                            "columns": t.columns.into_iter().map(|c| json!({
385                                "name": c.name,
386                                "type": c.column_type,
387                                "notnull": c.notnull,
388                                "pk": c.pk,
389                                "default_value": c.default_value
390                            })).collect::<Vec<_>>(),
391                            "row_count": t.row_count
392                        })
393                    })
394                    .collect();
395
396                ToolExecutionResult::success(json!({
397                    "database": database,
398                    "tables": tables_json
399                }))
400            }
401            Err(e) => sqldb_error_to_result(e),
402        }
403    }
404
405    fn requires_context(&self) -> bool {
406        true
407    }
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413    use crate::typed_id::SessionId;
414
415    #[test]
416    fn test_capability_metadata() {
417        let cap = SessionSqlDatabaseCapability;
418        assert_eq!(cap.id(), "session_sql_database");
419        assert_eq!(cap.name(), "SQL Database");
420        assert_eq!(cap.status(), CapabilityStatus::Available);
421        assert_eq!(cap.icon(), Some("database"));
422        assert_eq!(cap.category(), Some("Data"));
423    }
424
425    #[test]
426    fn test_capability_has_three_tools() {
427        let cap = SessionSqlDatabaseCapability;
428        let tools = cap.tools();
429        assert_eq!(tools.len(), 3);
430
431        let tool_names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
432        assert!(tool_names.contains(&"sql_execute"));
433        assert!(tool_names.contains(&"sql_query"));
434        assert!(tool_names.contains(&"sql_schema"));
435    }
436
437    #[test]
438    fn test_capability_has_system_prompt() {
439        let cap = SessionSqlDatabaseCapability;
440        let prompt = cap.system_prompt_addition().unwrap();
441        assert!(prompt.contains("SQLite"));
442        assert!(prompt.contains("1000 rows"));
443    }
444
445    #[test]
446    fn test_tools_require_context() {
447        assert!(SqlExecuteTool.requires_context());
448        assert!(SqlQueryTool.requires_context());
449        assert!(SqlSchemaTool.requires_context());
450    }
451
452    #[tokio::test]
453    async fn test_sql_execute_without_context() {
454        let tool = SqlExecuteTool;
455        let result = tool
456            .execute(json!({"database": "test", "sql": "SELECT 1"}))
457            .await;
458        assert!(matches!(result, ToolExecutionResult::ToolError(_)));
459    }
460
461    #[tokio::test]
462    async fn test_sql_execute_missing_params() {
463        let tool = SqlExecuteTool;
464        let context = ToolContext::new(SessionId::new());
465
466        let result = tool
467            .execute_with_context(json!({"database": "test"}), &context)
468            .await;
469        if let ToolExecutionResult::ToolError(msg) = result {
470            assert!(msg.contains("sql"));
471        } else {
472            panic!("Expected tool error for missing sql");
473        }
474    }
475
476    #[tokio::test]
477    async fn test_sql_execute_no_store() {
478        let tool = SqlExecuteTool;
479        let context = ToolContext::new(SessionId::new());
480
481        let result = tool
482            .execute_with_context(
483                json!({"database": "test", "sql": "CREATE TABLE t (id INTEGER)"}),
484                &context,
485            )
486            .await;
487        if let ToolExecutionResult::ToolError(msg) = result {
488            assert!(msg.contains("not available"));
489        } else {
490            panic!("Expected tool error for missing store");
491        }
492    }
493
494    // ============================================================================
495    // EVE-339 — Reading-tool truncation envelope conformance
496    // ============================================================================
497
498    #[test]
499    fn test_sql_query_truncation_envelope_when_not_truncated() {
500        let columns = vec!["id".to_string()];
501        let rows = vec![vec![json!(1)], vec![json!(2)]];
502        let response = shape_sql_query_response("db", &columns, &rows, 2, false);
503        crate::truncation_info::assert_conforms("sql_query", &response);
504        assert_eq!(response["truncation"]["truncated"], false);
505    }
506
507    #[test]
508    fn test_sql_query_truncation_envelope_when_truncated() {
509        let columns = vec!["id".to_string()];
510        let rows = vec![vec![json!(1)]; 1000];
511        let response = shape_sql_query_response("db", &columns, &rows, 1000, true);
512        crate::truncation_info::assert_conforms("sql_query", &response);
513        assert_eq!(response["truncation"]["truncated"], true);
514        assert_eq!(response["truncation"]["reason"], "row_cap");
515        assert!(
516            response["truncation"].get("next_offset").is_none(),
517            "sql_query does not support in-place resume"
518        );
519    }
520}