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