Skip to main content

roboticus_agent/tools/
introspection.rs

1// ── Introspection Tools ─────────────────────────────────────────────────────
2// Read-only probes that let the agent reason about its own runtime state.
3// All return JSON strings so the LLM can parse structured data.
4
5use super::{Tool, ToolContext, ToolError, ToolResult};
6use async_trait::async_trait;
7use roboticus_core::RiskLevel;
8use serde_json::{Value, json};
9
10/// Reports runtime context: agent id, session, channel, and workspace.
11pub struct GetRuntimeContextTool;
12
13#[async_trait]
14impl Tool for GetRuntimeContextTool {
15    fn name(&self) -> &str {
16        "get_runtime_context"
17    }
18
19    fn description(&self) -> &str {
20        "Returns runtime context: session, channel, workspace, allowed paths, and sandbox boundaries \
21         from roboticus.toml (filesystem + skill script policy). Includes `how_to_change_boundaries` \
22         with TOML keys and docs references, plus hippocampus-backed storage awareness when a database \
23         is available — call this before claiming the user is confined to a path; then explain how to \
24         widen access if needed."
25    }
26
27    fn risk_level(&self) -> RiskLevel {
28        RiskLevel::Safe
29    }
30
31    fn parameters_schema(&self) -> Value {
32        serde_json::json!({
33            "type": "object",
34            "properties": {},
35            "required": []
36        })
37    }
38
39    async fn execute(
40        &self,
41        _params: Value,
42        ctx: &ToolContext,
43    ) -> std::result::Result<ToolResult, ToolError> {
44        let tool_paths: Vec<String> = ctx
45            .tool_allowed_paths
46            .iter()
47            .map(|p| p.display().to_string())
48            .collect();
49        let script_paths: Vec<String> = ctx
50            .sandbox
51            .script_allowed_paths
52            .iter()
53            .map(|p| p.display().to_string())
54            .collect();
55        let storage = if let Some(db) = ctx.db.as_ref() {
56            match roboticus_db::hippocampus::list_tables(db) {
57                Ok(entries) => {
58                    let owned_tables: Vec<Value> = entries
59                        .iter()
60                        .filter(|entry| entry.agent_owned && entry.created_by == ctx.agent_id)
61                        .map(|entry| {
62                            serde_json::json!({
63                                "table_name": entry.table_name,
64                                "description": entry.description,
65                                "row_count": entry.row_count,
66                                "access_level": entry.access_level,
67                                "updated_at": entry.updated_at,
68                            })
69                        })
70                        .collect();
71                    let knowledge_sources: Vec<Value> = entries
72                        .iter()
73                        .filter(|entry| entry.table_name.starts_with("knowledge:"))
74                        .map(|entry| {
75                            serde_json::json!({
76                                "table_name": entry.table_name,
77                                "description": entry.description,
78                                "row_count": entry.row_count,
79                                "access_level": entry.access_level,
80                                "updated_at": entry.updated_at,
81                            })
82                        })
83                        .collect();
84                    let compact_summary =
85                        roboticus_db::hippocampus::compact_summary(db).unwrap_or_default();
86                    serde_json::json!({
87                        "hippocampus_available": true,
88                        "table_count": entries.len(),
89                        "owned_table_count": owned_tables.len(),
90                        "knowledge_source_count": knowledge_sources.len(),
91                        "compact_summary": compact_summary,
92                        "owned_tables": owned_tables,
93                        "knowledge_sources": knowledge_sources,
94                    })
95                }
96                Err(e) => serde_json::json!({
97                    "hippocampus_available": false,
98                    "error": format!("failed to load hippocampus: {e}"),
99                }),
100            }
101        } else {
102            serde_json::json!({
103                "hippocampus_available": false,
104                "note": "database not available",
105            })
106        };
107        let wallet = if let Some(db) = ctx.db.as_ref() {
108            match roboticus_db::treasury::get_treasury_state(db) {
109                Ok(Some(ts)) => serde_json::json!({
110                    "available": true,
111                    "usdc_balance": ts.usdc_balance,
112                    "native_balance": ts.native_balance,
113                    "survival_tier": format!("{:?}", ts.survival_tier),
114                    "last_updated": ts.updated_at,
115                }),
116                _ => serde_json::json!({
117                    "available": true,
118                    "note": "treasury state not yet cached — balance will appear after first heartbeat tick",
119                }),
120            }
121        } else {
122            serde_json::json!({ "available": false })
123        };
124        let info = serde_json::json!({
125            "agent_id": ctx.agent_id,
126            "agent_name": ctx.agent_name,
127            "session_id": ctx.session_id,
128            "channel": ctx.channel,
129            "workspace_root": ctx.workspace_root.display().to_string(),
130            "authority": format!("{:?}", ctx.authority),
131            "tool_allowed_paths": tool_paths,
132            "wallet": wallet,
133            "storage": storage,
134            "sandbox": {
135                "filesystem": {
136                    "workspace_only": ctx.sandbox.filesystem_workspace_only,
137                    "script_fs_confinement": ctx.sandbox.filesystem_script_fs_confinement,
138                    "script_allowed_paths": script_paths,
139                },
140                "skills": {
141                    "sandbox_env": ctx.sandbox.skills_sandbox_env,
142                    "network_allowed": ctx.sandbox.skills_network_allowed,
143                    "skills_dir": ctx.sandbox.skills_dir.display().to_string(),
144                },
145                "effective_file_tool_roots": {
146                    "primary_workspace": ctx.workspace_root.display().to_string(),
147                    "extra_tool_allowed_paths": ctx.tool_allowed_paths.iter().map(|p| p.display().to_string()).collect::<Vec<_>>(),
148                    "summary": "File tools resolve relative paths inside primary_workspace when workspace_only is true; absolute paths may use extra_tool_allowed_paths entries. Skill scripts use skills_dir plus script_allowed_paths (subject to script_fs_confinement)."
149                }
150            },
151            "how_to_change_boundaries": {
152                "documentation": "docs/CONFIGURATION.md — sections [agent], [security.filesystem], [skills]",
153                "toml_keys": [
154                    "[agent].workspace — default workspace root for file tools; ROBOTICUS_WORKSPACE for plugins/skills",
155                    "[security.filesystem].tool_allowed_paths — allow file tools / bash cwd under extra absolute directories",
156                    "[security.filesystem].script_allowed_paths — extra directories for sandboxed skill scripts",
157                    "[security.filesystem].workspace_only — when true, keep relative file paths inside workspace",
158                    "[security.filesystem].script_fs_confinement — OS-level sandbox for skill scripts",
159                    "[skills].sandbox_env — sanitize environment for skill subprocesses",
160                    "[skills].network_allowed — outbound network for skill scripts"
161                ],
162                "after_editing": "Restart roboticus serve (or your Roboticus process) so the new boundaries load.",
163                "cli": "roboticus config get <key> | roboticus config set <key> <value> | roboticus check"
164            }
165        });
166        Ok(ToolResult {
167            output: serde_json::to_string_pretty(&info).unwrap_or_else(|_| "{}".into()),
168            metadata: Some(info),
169        })
170    }
171}
172
173/// Reports memory budget allocation and retrieval tier configuration.
174pub struct GetMemoryStatsTool;
175
176#[async_trait]
177impl Tool for GetMemoryStatsTool {
178    fn name(&self) -> &str {
179        "get_memory_stats"
180    }
181
182    fn description(&self) -> &str {
183        "Returns memory retrieval tier allocations plus live memory health when a database is available, \
184         including active vs stale episodic/semantic counts, procedural utilization, and relationship interaction volume"
185    }
186
187    fn risk_level(&self) -> RiskLevel {
188        RiskLevel::Safe
189    }
190
191    fn parameters_schema(&self) -> Value {
192        serde_json::json!({
193            "type": "object",
194            "properties": {},
195            "required": []
196        })
197    }
198
199    async fn execute(
200        &self,
201        _params: Value,
202        ctx: &ToolContext,
203    ) -> std::result::Result<ToolResult, ToolError> {
204        // Return the default tier budgets; runtime overrides would require
205        // access to the live config, which we thread in when available.
206        let mut payload = serde_json::json!({
207            "tiers": {
208                "working": { "budget_pct": 30, "description": "Active conversation context" },
209                "episodic": { "budget_pct": 25, "description": "Session digests and summaries" },
210                "semantic": { "budget_pct": 20, "description": "Vector-similarity recalled facts" },
211                "procedural": { "budget_pct": 15, "description": "How-to knowledge and procedures" },
212                "relationship": { "budget_pct": 10, "description": "Entity relationships and graph" },
213            },
214            "retrieval_method": "5-tier hybrid (FTS5 + vector cosine)",
215            "lifecycle_policy": {
216                "inactive_states_suppressed_by_default": true,
217                "history_queries_can_include_inactive": true,
218                "notes": "stale episodic digests and superseded semantic summaries are hidden unless the task explicitly asks for historical or resolved context"
219            }
220        });
221        let live = if let Some(db) = ctx.db.as_ref() {
222            match roboticus_db::memory::memory_health_snapshot(db, &ctx.session_id) {
223                Ok(snapshot) => serde_json::json!({
224                    "available": true,
225                    "snapshot": snapshot,
226                }),
227                Err(e) => serde_json::json!({
228                    "available": false,
229                    "error": format!("failed to inspect live memory health: {e}"),
230                }),
231            }
232        } else {
233            serde_json::json!({
234                "available": false,
235                "note": "database not available",
236            })
237        };
238        payload["live"] = live;
239        Ok(ToolResult {
240            output: serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".into()),
241            metadata: Some(payload),
242        })
243    }
244}
245
246/// Reports the health of the current delivery channel.
247pub struct GetChannelHealthTool;
248
249#[async_trait]
250impl Tool for GetChannelHealthTool {
251    fn name(&self) -> &str {
252        "get_channel_health"
253    }
254
255    fn description(&self) -> &str {
256        "Returns the health status of the current delivery channel"
257    }
258
259    fn risk_level(&self) -> RiskLevel {
260        RiskLevel::Safe
261    }
262
263    fn parameters_schema(&self) -> Value {
264        serde_json::json!({
265            "type": "object",
266            "properties": {},
267            "required": []
268        })
269    }
270
271    async fn execute(
272        &self,
273        _params: Value,
274        ctx: &ToolContext,
275    ) -> std::result::Result<ToolResult, ToolError> {
276        let channel = ctx.channel.as_deref().unwrap_or("unknown");
277        let health = serde_json::json!({
278            "channel": channel,
279            "status": "operational",
280            "note": "Detailed channel health metrics require a ChannelRouter reference; \
281                     basic connectivity confirmed by successful tool invocation.",
282        });
283        Ok(ToolResult {
284            output: serde_json::to_string_pretty(&health).unwrap_or_else(|_| "{}".into()),
285            metadata: Some(health),
286        })
287    }
288}
289
290// ── Subagent & Task Introspection ──────────────────────────────────────
291
292/// Returns the status of registered subagents and open tasks.
293///
294/// Designed to grow over time — future versions may include delegation
295/// history, task completion rates, and specialist performance metrics.
296pub struct GetSubagentStatusTool;
297
298#[async_trait]
299impl Tool for GetSubagentStatusTool {
300    fn name(&self) -> &str {
301        "get_subagent_status"
302    }
303
304    fn description(&self) -> &str {
305        "Returns what subagents (specialists) are available, their skills, capabilities, \
306         and current status. Use this tool when asked about subagent capabilities, what \
307         specialists can do, or the delegation roster."
308    }
309
310    fn risk_level(&self) -> RiskLevel {
311        RiskLevel::Safe
312    }
313
314    fn parameters_schema(&self) -> Value {
315        serde_json::json!({
316            "type": "object",
317            "properties": {},
318            "required": []
319        })
320    }
321
322    async fn execute(
323        &self,
324        _params: Value,
325        ctx: &ToolContext,
326    ) -> std::result::Result<ToolResult, ToolError> {
327        let db = match &ctx.db {
328            Some(db) => db,
329            None => {
330                let result = serde_json::json!({
331                    "error": "database not available",
332                    "subagents": [],
333                    "tasks": [],
334                });
335                return Ok(ToolResult {
336                    output: serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".into()),
337                    metadata: Some(result),
338                });
339            }
340        };
341
342        // Query subagents
343        let subagents = roboticus_db::agents::list_sub_agents(db)
344            .unwrap_or_default()
345            .into_iter()
346            .map(|a| {
347                serde_json::json!({
348                    "name": a.name,
349                    "display_name": a.display_name,
350                    "model": a.model,
351                    "role": a.role,
352                    "enabled": a.enabled,
353                    "session_count": a.session_count,
354                })
355            })
356            .collect::<Vec<_>>();
357
358        // Query open tasks
359        let tasks = {
360            let conn = db.conn();
361            conn.prepare(
362                "SELECT id, title, status, priority, source, created_at \
363                 FROM tasks WHERE status IN ('pending', 'in_progress') \
364                 ORDER BY priority DESC, created_at ASC LIMIT 50",
365            )
366            .ok()
367            .map(|mut stmt| {
368                stmt.query_map([], |row| {
369                    let source_raw: Option<String> = row.get(4)?;
370                    Ok(serde_json::json!({
371                        "id": row.get::<_, String>(0)?,
372                        "title": row.get::<_, String>(1)?,
373                        "status": row.get::<_, String>(2)?,
374                        "priority": row.get::<_, i64>(3)?,
375                        "source": roboticus_db::tasks::normalize_task_source_value(source_raw.as_deref()),
376                        "created_at": row.get::<_, String>(5)?,
377                    }))
378                })
379                .inspect_err(|e| tracing::warn!("failed to query tasks: {e}"))
380                .ok()
381                .map(|rows| rows.filter_map(|r| {
382                    r.inspect_err(|e| tracing::warn!("skipping corrupted task row: {e}"))
383                        .ok()
384                }).collect::<Vec<_>>())
385                .unwrap_or_default()
386            })
387            .unwrap_or_default()
388        };
389
390        let result = serde_json::json!({
391            "subagents": subagents,
392            "subagent_count": subagents.len(),
393            "tasks": tasks,
394            "open_task_count": tasks.len(),
395        });
396        Ok(ToolResult {
397            output: serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".into()),
398            metadata: Some(result),
399        })
400    }
401}
402
403/// Recall full content from the memory index by entry ID.
404///
405/// The memory index injects lightweight summaries into the context. When the
406/// model needs full details, it calls this tool with the index entry ID to
407/// fetch the complete content from the source memory tier.
408pub struct RecallMemoryTool;
409
410#[async_trait::async_trait]
411impl Tool for RecallMemoryTool {
412    fn name(&self) -> &str {
413        "recall_memory"
414    }
415
416    fn description(&self) -> &str {
417        "Fetch full content for a memory index entry. Pass the index entry ID (e.g., 'idx-episodic_memory-ep001') to retrieve the complete memory content."
418    }
419
420    fn risk_level(&self) -> RiskLevel {
421        RiskLevel::Safe
422    }
423
424    fn parameters_schema(&self) -> Value {
425        json!({
426            "type": "object",
427            "properties": {
428                "id": {
429                    "type": "string",
430                    "description": "The memory index entry ID from the [Memory Index] block"
431                }
432            },
433            "required": ["id"]
434        })
435    }
436
437    async fn execute(
438        &self,
439        params: Value,
440        ctx: &ToolContext,
441    ) -> std::result::Result<ToolResult, ToolError> {
442        let index_id = params
443            .get("id")
444            .and_then(|v| v.as_str())
445            .ok_or_else(|| ToolError {
446                message: "missing 'id' parameter".into(),
447            })?;
448
449        let db = ctx.db.as_ref().ok_or_else(|| ToolError {
450            message: "database not available".into(),
451        })?;
452
453        // Look up the index entry to find the source table and ID
454        let entry = roboticus_db::memory_index::top_entries(db, 100)
455            .map_err(|e| ToolError {
456                message: format!("index query failed: {e}"),
457            })?
458            .into_iter()
459            .find(|e| e.id == index_id);
460
461        let entry = match entry {
462            Some(e) => e,
463            None => {
464                return Ok(ToolResult {
465                    output: format!("No memory index entry found with ID: {index_id}"),
466                    metadata: None,
467                });
468            }
469        };
470
471        // Fetch the full content from the source tier
472        let content =
473            roboticus_db::memory_index::recall_content(db, &entry.source_table, &entry.source_id)
474                .map_err(|e| ToolError {
475                message: format!("recall failed: {e}"),
476            })?;
477
478        let result = json!({
479            "id": index_id,
480            "source": entry.source_table,
481            "category": entry.category,
482            "confidence": entry.confidence,
483            "content": content.unwrap_or_else(|| "(content no longer available — source may have been pruned)".into()),
484        });
485
486        Ok(ToolResult {
487            output: serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".into()),
488            metadata: Some(result),
489        })
490    }
491}