everruns_core/capabilities/
session_sql_database.rs1use 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
19pub 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
74fn 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
86fn 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 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
125pub 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 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
228pub 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
334pub 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 #[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 #[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}