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 narrate(
134        &self,
135        tool_call: &crate::tool_types::ToolCall,
136        phase: crate::tool_narration::ToolNarrationPhase,
137        locale: Option<&str>,
138        _ctx: crate::tool_narration::ToolNarrationContext<'_>,
139    ) -> Option<String> {
140        crate::tool_narration::narrate_sql(self.name(), &tool_call.arguments, phase, locale)
141    }
142
143    fn name(&self) -> &str {
144        "sql_execute"
145    }
146
147    fn display_name(&self) -> Option<&str> {
148        Some("SQL Execute")
149    }
150
151    fn description(&self) -> &str {
152        "Execute DDL/DML SQL (CREATE TABLE, INSERT, UPDATE, DELETE). Auto-creates database if it doesn't exist."
153    }
154
155    fn parameters_schema(&self) -> Value {
156        json!({
157            "type": "object",
158            "properties": {
159                "database": {
160                    "type": "string",
161                    "description": "Database name (alphanumeric + underscores)"
162                },
163                "sql": {
164                    "type": "string",
165                    "description": "SQL statement(s) to execute"
166                }
167            },
168            "required": ["database", "sql"],
169            "additionalProperties": false
170        })
171    }
172
173    fn hints(&self) -> ToolHints {
174        // Mutates the shared session SQL database (DDL/DML): serialize
175        // concurrent sql_execute calls within a batch to avoid write races.
176        ToolHints::default().with_concurrency_class("session_sql")
177    }
178
179    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
180        ToolExecutionResult::tool_error(
181            "sql_execute requires context. This tool must be executed with session context.",
182        )
183    }
184
185    async fn execute_with_context(
186        &self,
187        arguments: Value,
188        context: &ToolContext,
189    ) -> ToolExecutionResult {
190        let database = match arguments.get("database").and_then(|v| v.as_str()) {
191            Some(d) => d,
192            None => {
193                return ToolExecutionResult::tool_error("Missing required parameter: database");
194            }
195        };
196
197        let sql = match arguments.get("sql").and_then(|v| v.as_str()) {
198            Some(s) => s,
199            None => {
200                return ToolExecutionResult::tool_error("Missing required parameter: sql");
201            }
202        };
203
204        let store = match &context.sqldb_store {
205            Some(store) => store,
206            None => {
207                return ToolExecutionResult::tool_error(
208                    "SQL database not available in this context",
209                );
210            }
211        };
212
213        match store.sql_execute(context.session_id, database, sql).await {
214            Ok(result) => ToolExecutionResult::success(json!({
215                "database": database,
216                "success": true,
217                "rows_affected": result.rows_affected
218            })),
219            Err(e) => sqldb_error_to_result(e),
220        }
221    }
222
223    fn requires_context(&self) -> bool {
224        true
225    }
226}
227
228// ============================================================================
229// SqlQueryTool
230// ============================================================================
231
232pub struct SqlQueryTool;
233
234#[async_trait]
235impl Tool for SqlQueryTool {
236    fn narrate(
237        &self,
238        tool_call: &crate::tool_types::ToolCall,
239        phase: crate::tool_narration::ToolNarrationPhase,
240        locale: Option<&str>,
241        _ctx: crate::tool_narration::ToolNarrationContext<'_>,
242    ) -> Option<String> {
243        crate::tool_narration::narrate_sql(self.name(), &tool_call.arguments, phase, locale)
244    }
245
246    fn name(&self) -> &str {
247        "sql_query"
248    }
249
250    fn display_name(&self) -> Option<&str> {
251        Some("SQL Query")
252    }
253
254    fn description(&self) -> &str {
255        "Execute a read-only SQL query (SELECT). Returns columns and rows as JSON."
256    }
257
258    fn parameters_schema(&self) -> Value {
259        json!({
260            "type": "object",
261            "properties": {
262                "database": {
263                    "type": "string",
264                    "description": "Database name"
265                },
266                "sql": {
267                    "type": "string",
268                    "description": "SELECT query"
269                }
270            },
271            "required": ["database", "sql"],
272            "additionalProperties": false
273        })
274    }
275
276    fn hints(&self) -> ToolHints {
277        ToolHints::default().with_readonly(true)
278    }
279
280    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
281        ToolExecutionResult::tool_error(
282            "sql_query requires context. This tool must be executed with session context.",
283        )
284    }
285
286    async fn execute_with_context(
287        &self,
288        arguments: Value,
289        context: &ToolContext,
290    ) -> ToolExecutionResult {
291        let database = match arguments.get("database").and_then(|v| v.as_str()) {
292            Some(d) => d,
293            None => {
294                return ToolExecutionResult::tool_error("Missing required parameter: database");
295            }
296        };
297
298        let sql = match arguments.get("sql").and_then(|v| v.as_str()) {
299            Some(s) => s,
300            None => {
301                return ToolExecutionResult::tool_error("Missing required parameter: sql");
302            }
303        };
304
305        let store = match &context.sqldb_store {
306            Some(store) => store,
307            None => {
308                return ToolExecutionResult::tool_error(
309                    "SQL database not available in this context",
310                );
311            }
312        };
313
314        match store.sql_query(context.session_id, database, sql).await {
315            Ok(result) => {
316                let response = shape_sql_query_response(
317                    database,
318                    &result.columns,
319                    &result.rows,
320                    result.row_count,
321                    result.truncated,
322                );
323                ToolExecutionResult::success(response)
324            }
325            Err(e) => sqldb_error_to_result(e),
326        }
327    }
328
329    fn requires_context(&self) -> bool {
330        true
331    }
332}
333
334// ============================================================================
335// SqlSchemaTool
336// ============================================================================
337
338pub struct SqlSchemaTool;
339
340#[async_trait]
341impl Tool for SqlSchemaTool {
342    fn narrate(
343        &self,
344        tool_call: &crate::tool_types::ToolCall,
345        phase: crate::tool_narration::ToolNarrationPhase,
346        locale: Option<&str>,
347        _ctx: crate::tool_narration::ToolNarrationContext<'_>,
348    ) -> Option<String> {
349        crate::tool_narration::narrate_sql(self.name(), &tool_call.arguments, phase, locale)
350    }
351
352    fn name(&self) -> &str {
353        "sql_schema"
354    }
355
356    fn display_name(&self) -> Option<&str> {
357        Some("SQL Schema")
358    }
359
360    fn description(&self) -> &str {
361        "Introspect database schema: tables, columns, types, and row counts."
362    }
363
364    fn parameters_schema(&self) -> Value {
365        json!({
366            "type": "object",
367            "properties": {
368                "database": {
369                    "type": "string",
370                    "description": "Database name"
371                },
372                "table": {
373                    "type": "string",
374                    "description": "Specific table name (optional, omit to list all tables)"
375                }
376            },
377            "required": ["database"],
378            "additionalProperties": false
379        })
380    }
381
382    fn hints(&self) -> ToolHints {
383        ToolHints::default()
384            .with_readonly(true)
385            .with_idempotent(true)
386    }
387
388    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
389        ToolExecutionResult::tool_error(
390            "sql_schema requires context. This tool must be executed with session context.",
391        )
392    }
393
394    async fn execute_with_context(
395        &self,
396        arguments: Value,
397        context: &ToolContext,
398    ) -> ToolExecutionResult {
399        let database = match arguments.get("database").and_then(|v| v.as_str()) {
400            Some(d) => d,
401            None => {
402                return ToolExecutionResult::tool_error("Missing required parameter: database");
403            }
404        };
405
406        let table = arguments.get("table").and_then(|v| v.as_str());
407
408        let store = match &context.sqldb_store {
409            Some(store) => store,
410            None => {
411                return ToolExecutionResult::tool_error(
412                    "SQL database not available in this context",
413                );
414            }
415        };
416
417        match store.sql_schema(context.session_id, database, table).await {
418            Ok(tables) => {
419                let tables_json: Vec<Value> = tables
420                    .into_iter()
421                    .map(|t| {
422                        json!({
423                            "name": t.name,
424                            "columns": t.columns.into_iter().map(|c| json!({
425                                "name": c.name,
426                                "type": c.column_type,
427                                "notnull": c.notnull,
428                                "pk": c.pk,
429                                "default_value": c.default_value
430                            })).collect::<Vec<_>>(),
431                            "row_count": t.row_count
432                        })
433                    })
434                    .collect();
435
436                ToolExecutionResult::success(json!({
437                    "database": database,
438                    "tables": tables_json
439                }))
440            }
441            Err(e) => sqldb_error_to_result(e),
442        }
443    }
444
445    fn requires_context(&self) -> bool {
446        true
447    }
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453    use crate::typed_id::SessionId;
454
455    // Metadata/tool-list constants covered by builtin_capabilities_satisfy_registry_invariants.
456
457    #[test]
458    fn test_capability_has_system_prompt() {
459        let cap = SessionSqlDatabaseCapability;
460        let prompt = cap.system_prompt_addition().unwrap();
461        assert!(prompt.contains("SQLite"));
462        assert!(prompt.contains("1000 rows"));
463    }
464
465    #[tokio::test]
466    async fn test_sql_execute_without_context() {
467        let tool = SqlExecuteTool;
468        let result = tool
469            .execute(json!({"database": "test", "sql": "SELECT 1"}))
470            .await;
471        assert!(matches!(result, ToolExecutionResult::ToolError(_)));
472    }
473
474    #[tokio::test]
475    async fn test_sql_execute_missing_params() {
476        let tool = SqlExecuteTool;
477        let context = ToolContext::new(SessionId::new());
478
479        let result = tool
480            .execute_with_context(json!({"database": "test"}), &context)
481            .await;
482        if let ToolExecutionResult::ToolError(msg) = result {
483            assert!(msg.contains("sql"));
484        } else {
485            panic!("Expected tool error for missing sql");
486        }
487    }
488
489    #[tokio::test]
490    async fn test_sql_execute_no_store() {
491        let tool = SqlExecuteTool;
492        let context = ToolContext::new(SessionId::new());
493
494        let result = tool
495            .execute_with_context(
496                json!({"database": "test", "sql": "CREATE TABLE t (id INTEGER)"}),
497                &context,
498            )
499            .await;
500        if let ToolExecutionResult::ToolError(msg) = result {
501            assert!(msg.contains("not available"));
502        } else {
503            panic!("Expected tool error for missing store");
504        }
505    }
506
507    // ============================================================================
508    // EVE-339 — Reading-tool truncation envelope conformance
509    // ============================================================================
510
511    #[test]
512    fn test_sql_query_truncation_envelope_when_not_truncated() {
513        let columns = vec!["id".to_string()];
514        let rows = vec![vec![json!(1)], vec![json!(2)]];
515        let response = shape_sql_query_response("db", &columns, &rows, 2, false);
516        crate::truncation_info::assert_conforms("sql_query", &response);
517        assert_eq!(response["truncation"]["truncated"], false);
518    }
519
520    #[test]
521    fn test_sql_query_truncation_envelope_when_truncated() {
522        let columns = vec!["id".to_string()];
523        let rows = vec![vec![json!(1)]; 1000];
524        let response = shape_sql_query_response("db", &columns, &rows, 1000, true);
525        crate::truncation_info::assert_conforms("sql_query", &response);
526        assert_eq!(response["truncation"]["truncated"], true);
527        assert_eq!(response["truncation"]["reason"], "row_cap");
528        assert!(
529            response["truncation"].get("next_offset").is_none(),
530            "sql_query does not support in-place resume"
531        );
532    }
533}