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 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 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
218pub 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
314pub 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 #[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 #[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}