Skip to main content

roboticus_agent/tools/
data.rs

1// ── Agent Data Tools ───────────────────────────────────────────────────
2// Let the agent create, modify, and drop its own database tables.
3// All tables are prefixed with the agent id for isolation.
4
5use super::{Tool, ToolContext, ToolError, ToolResult};
6use async_trait::async_trait;
7use roboticus_core::RiskLevel;
8use serde_json::Value;
9
10pub(crate) const MAX_AGENT_TABLES: usize = 50;
11pub(crate) const MAX_COLUMNS_PER_TABLE: usize = 64;
12pub(crate) const ALLOWED_COL_TYPES: &[&str] = &["TEXT", "INTEGER", "REAL", "BLOB"];
13pub(crate) const RESERVED_COL_NAMES: &[&str] = &["id", "created_at", "rowid"];
14
15fn require_db(ctx: &ToolContext) -> std::result::Result<&roboticus_db::Database, ToolError> {
16    ctx.db.as_ref().ok_or_else(|| ToolError {
17        message: "database not available in this context".into(),
18    })
19}
20
21fn parse_column_defs(
22    raw: &[Value],
23) -> std::result::Result<Vec<roboticus_db::hippocampus::ColumnDef>, ToolError> {
24    let mut cols = Vec::with_capacity(raw.len());
25    for (i, v) in raw.iter().enumerate() {
26        let name = v
27            .get("name")
28            .and_then(|n| n.as_str())
29            .ok_or_else(|| ToolError {
30                message: format!("column {i}: missing 'name'"),
31            })?;
32
33        if RESERVED_COL_NAMES.contains(&name.to_lowercase().as_str()) {
34            return Err(ToolError {
35                message: format!("column '{name}' is reserved and added automatically"),
36            });
37        }
38
39        let col_type = v
40            .get("type")
41            .and_then(|t| t.as_str())
42            .unwrap_or("TEXT")
43            .to_uppercase();
44
45        if !ALLOWED_COL_TYPES.contains(&col_type.as_str()) {
46            return Err(ToolError {
47                message: format!(
48                    "column '{name}': type '{col_type}' not allowed (use TEXT, INTEGER, REAL, or BLOB)"
49                ),
50            });
51        }
52
53        let nullable = v.get("nullable").and_then(|n| n.as_bool()).unwrap_or(true);
54        let description = v
55            .get("description")
56            .and_then(|d| d.as_str())
57            .map(String::from);
58
59        cols.push(roboticus_db::hippocampus::ColumnDef {
60            name: name.into(),
61            col_type,
62            nullable,
63            description,
64        });
65    }
66    Ok(cols)
67}
68
69/// Creates a new agent-owned database table. Tables are automatically
70/// prefixed with the agent id and registered in the hippocampus.
71pub struct CreateTableTool;
72
73#[async_trait]
74impl Tool for CreateTableTool {
75    fn name(&self) -> &str {
76        "create_table"
77    }
78
79    fn description(&self) -> &str {
80        "Create a new database table owned by this agent. Tables are prefixed with the agent id \
81         for isolation. Columns 'id' (TEXT PK) and 'created_at' are added automatically."
82    }
83
84    fn risk_level(&self) -> RiskLevel {
85        RiskLevel::Caution
86    }
87
88    fn parameters_schema(&self) -> Value {
89        serde_json::json!({
90            "type": "object",
91            "properties": {
92                "name": {
93                    "type": "string",
94                    "description": "Table suffix (will be prefixed with agent id). Alphanumeric and underscores only."
95                },
96                "description": {
97                    "type": "string",
98                    "description": "Human-readable description of the table's purpose"
99                },
100                "columns": {
101                    "type": "array",
102                    "description": "Column definitions. Each has 'name', optional 'type' (TEXT|INTEGER|REAL|BLOB, default TEXT), optional 'nullable' (default true), optional 'description'.",
103                    "items": {
104                        "type": "object",
105                        "properties": {
106                            "name": { "type": "string" },
107                            "type": { "type": "string" },
108                            "nullable": { "type": "boolean" },
109                            "description": { "type": "string" }
110                        },
111                        "required": ["name"]
112                    }
113                }
114            },
115            "required": ["name", "description", "columns"]
116        })
117    }
118
119    async fn execute(
120        &self,
121        params: Value,
122        ctx: &ToolContext,
123    ) -> std::result::Result<ToolResult, ToolError> {
124        let db = require_db(ctx)?;
125
126        let name = params
127            .get("name")
128            .and_then(|v| v.as_str())
129            .ok_or_else(|| ToolError {
130                message: "missing 'name' parameter".into(),
131            })?;
132        let description = params
133            .get("description")
134            .and_then(|v| v.as_str())
135            .ok_or_else(|| ToolError {
136                message: "missing 'description' parameter".into(),
137            })?;
138        let raw_columns = params
139            .get("columns")
140            .and_then(|v| v.as_array())
141            .ok_or_else(|| ToolError {
142                message: "missing 'columns' array parameter".into(),
143            })?;
144
145        if raw_columns.len() > MAX_COLUMNS_PER_TABLE {
146            return Err(ToolError {
147                message: format!(
148                    "too many columns ({}, max {MAX_COLUMNS_PER_TABLE})",
149                    raw_columns.len()
150                ),
151            });
152        }
153
154        // Enforce per-agent table limit
155        let existing =
156            roboticus_db::hippocampus::list_agent_tables(db, &ctx.agent_id).map_err(|e| {
157                ToolError {
158                    message: format!("failed to check existing tables: {e}"),
159                }
160            })?;
161        if existing.len() >= MAX_AGENT_TABLES {
162            return Err(ToolError {
163                message: format!(
164                    "agent table limit reached ({MAX_AGENT_TABLES}). Drop unused tables first."
165                ),
166            });
167        }
168
169        let columns = parse_column_defs(raw_columns)?;
170
171        let full_name = roboticus_db::hippocampus::create_agent_table(
172            db,
173            &ctx.agent_id,
174            name,
175            description,
176            &columns,
177        )
178        .map_err(|e| ToolError {
179            message: format!("failed to create table: {e}"),
180        })?;
181
182        let result = serde_json::json!({
183            "table_name": full_name,
184            "columns_created": columns.len(),
185            "note": "Columns 'id' (TEXT PK) and 'created_at' (TEXT) are added automatically."
186        });
187        Ok(ToolResult {
188            output: serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".into()),
189            metadata: Some(result),
190        })
191    }
192}
193
194/// Adds or drops columns on an agent-owned table.
195pub struct AlterTableTool;
196
197#[async_trait]
198impl Tool for AlterTableTool {
199    fn name(&self) -> &str {
200        "alter_table"
201    }
202
203    fn description(&self) -> &str {
204        "Add or drop columns on a table owned by this agent. Use operation 'add_column' or 'drop_column'."
205    }
206
207    fn risk_level(&self) -> RiskLevel {
208        RiskLevel::Caution
209    }
210
211    fn parameters_schema(&self) -> Value {
212        serde_json::json!({
213            "type": "object",
214            "properties": {
215                "table_name": {
216                    "type": "string",
217                    "description": "Full table name (including agent prefix)"
218                },
219                "operation": {
220                    "type": "string",
221                    "enum": ["add_column", "drop_column"],
222                    "description": "The alteration to perform"
223                },
224                "column": {
225                    "type": "object",
226                    "description": "Column definition for add_column: {name, type?, nullable?, description?}. For drop_column: {name}.",
227                    "properties": {
228                        "name": { "type": "string" },
229                        "type": { "type": "string" },
230                        "nullable": { "type": "boolean" },
231                        "description": { "type": "string" }
232                    },
233                    "required": ["name"]
234                }
235            },
236            "required": ["table_name", "operation", "column"]
237        })
238    }
239
240    async fn execute(
241        &self,
242        params: Value,
243        ctx: &ToolContext,
244    ) -> std::result::Result<ToolResult, ToolError> {
245        let db = require_db(ctx)?;
246
247        let table_name = params
248            .get("table_name")
249            .and_then(|v| v.as_str())
250            .ok_or_else(|| ToolError {
251                message: "missing 'table_name' parameter".into(),
252            })?;
253        let operation = params
254            .get("operation")
255            .and_then(|v| v.as_str())
256            .ok_or_else(|| ToolError {
257                message: "missing 'operation' parameter".into(),
258            })?;
259        let column = params.get("column").ok_or_else(|| ToolError {
260            message: "missing 'column' parameter".into(),
261        })?;
262
263        let col_name = column
264            .get("name")
265            .and_then(|v| v.as_str())
266            .ok_or_else(|| ToolError {
267                message: "column missing 'name' field".into(),
268            })?;
269
270        // Verify ownership via hippocampus
271        let entry = roboticus_db::hippocampus::get_table(db, table_name)
272            .map_err(|e| ToolError {
273                message: format!("failed to look up table: {e}"),
274            })?
275            .ok_or_else(|| ToolError {
276                message: format!("table '{table_name}' not found in hippocampus"),
277            })?;
278
279        if !entry.agent_owned || entry.created_by != ctx.agent_id {
280            return Err(ToolError {
281                message: format!("table '{table_name}' is not owned by this agent"),
282            });
283        }
284
285        match operation {
286            "add_column" => {
287                if RESERVED_COL_NAMES.contains(&col_name.to_lowercase().as_str()) {
288                    return Err(ToolError {
289                        message: format!("column '{col_name}' is reserved"),
290                    });
291                }
292
293                let col_type = column
294                    .get("type")
295                    .and_then(|t| t.as_str())
296                    .unwrap_or("TEXT")
297                    .to_uppercase();
298
299                if !ALLOWED_COL_TYPES.contains(&col_type.as_str()) {
300                    return Err(ToolError {
301                        message: format!("type '{col_type}' not allowed"),
302                    });
303                }
304
305                let nullable = column
306                    .get("nullable")
307                    .and_then(|n| n.as_bool())
308                    .unwrap_or(true);
309
310                // Validate identifier safety
311                if !col_name
312                    .chars()
313                    .all(|c| c.is_ascii_alphanumeric() || c == '_')
314                    || col_name.is_empty()
315                {
316                    return Err(ToolError {
317                        message: format!("invalid column name: '{col_name}'"),
318                    });
319                }
320
321                let null_clause = if nullable { "" } else { " NOT NULL DEFAULT ''" };
322                let sql = format!(
323                    "ALTER TABLE \"{}\" ADD COLUMN {} {}{}",
324                    table_name, col_name, col_type, null_clause
325                );
326                let conn = db.conn();
327                conn.execute(&sql, []).map_err(|e| ToolError {
328                    message: format!("ALTER TABLE failed: {e}"),
329                })?;
330
331                // Re-introspect and update hippocampus
332                let description = column
333                    .get("description")
334                    .and_then(|d| d.as_str())
335                    .map(String::from);
336                let mut new_columns = entry.columns.clone();
337                new_columns.push(roboticus_db::hippocampus::ColumnDef {
338                    name: col_name.into(),
339                    col_type: col_type.clone(),
340                    nullable,
341                    description,
342                });
343                drop(conn);
344                roboticus_db::hippocampus::register_table(
345                    db,
346                    table_name,
347                    &entry.description,
348                    &new_columns,
349                    &entry.created_by,
350                    true,
351                    &entry.access_level,
352                    entry.row_count,
353                )
354                .map_err(|e| ToolError {
355                    message: format!("failed to update hippocampus: {e}"),
356                })?;
357
358                let result = serde_json::json!({
359                    "table_name": table_name,
360                    "operation": "add_column",
361                    "column_name": col_name,
362                    "column_type": col_type,
363                });
364                Ok(ToolResult {
365                    output: serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".into()),
366                    metadata: Some(result),
367                })
368            }
369            "drop_column" => {
370                // Validate identifier safety (same check as add_column)
371                if !col_name
372                    .chars()
373                    .all(|c| c.is_ascii_alphanumeric() || c == '_')
374                    || col_name.is_empty()
375                {
376                    return Err(ToolError {
377                        message: format!("invalid column name: '{col_name}'"),
378                    });
379                }
380
381                if RESERVED_COL_NAMES.contains(&col_name.to_lowercase().as_str()) {
382                    return Err(ToolError {
383                        message: format!("cannot drop reserved column '{col_name}'"),
384                    });
385                }
386
387                let sql = format!(
388                    "ALTER TABLE \"{}\" DROP COLUMN \"{}\"",
389                    table_name, col_name
390                );
391                let conn = db.conn();
392                conn.execute(&sql, []).map_err(|e| ToolError {
393                    message: format!("ALTER TABLE DROP COLUMN failed: {e}"),
394                })?;
395
396                // Update hippocampus entry
397                let new_columns: Vec<_> = entry
398                    .columns
399                    .iter()
400                    .filter(|c| c.name != col_name)
401                    .cloned()
402                    .collect();
403                drop(conn);
404                roboticus_db::hippocampus::register_table(
405                    db,
406                    table_name,
407                    &entry.description,
408                    &new_columns,
409                    &entry.created_by,
410                    true,
411                    &entry.access_level,
412                    entry.row_count,
413                )
414                .map_err(|e| ToolError {
415                    message: format!("failed to update hippocampus: {e}"),
416                })?;
417
418                let result = serde_json::json!({
419                    "table_name": table_name,
420                    "operation": "drop_column",
421                    "column_name": col_name,
422                });
423                Ok(ToolResult {
424                    output: serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".into()),
425                    metadata: Some(result),
426                })
427            }
428            other => Err(ToolError {
429                message: format!("unknown operation '{other}' (use 'add_column' or 'drop_column')"),
430            }),
431        }
432    }
433}
434
435/// Drops an agent-owned table and removes it from the hippocampus.
436pub struct DropTableTool;
437
438#[async_trait]
439impl Tool for DropTableTool {
440    fn name(&self) -> &str {
441        "drop_table"
442    }
443
444    fn description(&self) -> &str {
445        "Drop a table owned by this agent. The table and all its data are permanently deleted."
446    }
447
448    fn risk_level(&self) -> RiskLevel {
449        RiskLevel::Caution
450    }
451
452    fn parameters_schema(&self) -> Value {
453        serde_json::json!({
454            "type": "object",
455            "properties": {
456                "table_name": {
457                    "type": "string",
458                    "description": "Full table name (including agent prefix) to drop"
459                }
460            },
461            "required": ["table_name"]
462        })
463    }
464
465    async fn execute(
466        &self,
467        params: Value,
468        ctx: &ToolContext,
469    ) -> std::result::Result<ToolResult, ToolError> {
470        let db = require_db(ctx)?;
471
472        let table_name = params
473            .get("table_name")
474            .and_then(|v| v.as_str())
475            .ok_or_else(|| ToolError {
476                message: "missing 'table_name' parameter".into(),
477            })?;
478
479        roboticus_db::hippocampus::drop_agent_table(db, &ctx.agent_id, table_name).map_err(
480            |e| ToolError {
481                message: format!("failed to drop table: {e}"),
482            },
483        )?;
484
485        let result = serde_json::json!({
486            "table_name": table_name,
487            "status": "dropped",
488        });
489        Ok(ToolResult {
490            output: serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".into()),
491            metadata: Some(result),
492        })
493    }
494}