everruns_core/capabilities/
session_sql_database.rs1use 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
17pub 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
64fn 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
76fn 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 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
115pub 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 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
208pub 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
304pub 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 #[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}