1use 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
69pub 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 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
194pub 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 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 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 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 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 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
435pub 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}