roboticus-agent 0.11.0

Agent core with ReAct loop, policy engine, injection defense, memory system, and skill loader
Documentation
// ── Introspection Tools ─────────────────────────────────────────────────────
// Read-only probes that let the agent reason about its own runtime state.
// All return JSON strings so the LLM can parse structured data.

use super::{Tool, ToolContext, ToolError, ToolResult};
use async_trait::async_trait;
use roboticus_core::RiskLevel;
use serde_json::Value;

/// Reports runtime context: agent id, session, channel, and workspace.
pub struct GetRuntimeContextTool;

#[async_trait]
impl Tool for GetRuntimeContextTool {
    fn name(&self) -> &str {
        "get_runtime_context"
    }

    fn description(&self) -> &str {
        "Returns runtime context: session, channel, workspace, allowed paths, and sandbox boundaries \
         from roboticus.toml (filesystem + skill script policy). Includes `how_to_change_boundaries` \
         with TOML keys and docs references, plus hippocampus-backed storage awareness when a database \
         is available — call this before claiming the user is confined to a path; then explain how to \
         widen access if needed."
    }

    fn risk_level(&self) -> RiskLevel {
        RiskLevel::Safe
    }

    fn parameters_schema(&self) -> Value {
        serde_json::json!({
            "type": "object",
            "properties": {},
            "required": []
        })
    }

    async fn execute(
        &self,
        _params: Value,
        ctx: &ToolContext,
    ) -> std::result::Result<ToolResult, ToolError> {
        let tool_paths: Vec<String> = ctx
            .tool_allowed_paths
            .iter()
            .map(|p| p.display().to_string())
            .collect();
        let script_paths: Vec<String> = ctx
            .sandbox
            .script_allowed_paths
            .iter()
            .map(|p| p.display().to_string())
            .collect();
        let storage = if let Some(db) = ctx.db.as_ref() {
            match roboticus_db::hippocampus::list_tables(db) {
                Ok(entries) => {
                    let owned_tables: Vec<Value> = entries
                        .iter()
                        .filter(|entry| entry.agent_owned && entry.created_by == ctx.agent_id)
                        .map(|entry| {
                            serde_json::json!({
                                "table_name": entry.table_name,
                                "description": entry.description,
                                "row_count": entry.row_count,
                                "access_level": entry.access_level,
                                "updated_at": entry.updated_at,
                            })
                        })
                        .collect();
                    let knowledge_sources: Vec<Value> = entries
                        .iter()
                        .filter(|entry| entry.table_name.starts_with("knowledge:"))
                        .map(|entry| {
                            serde_json::json!({
                                "table_name": entry.table_name,
                                "description": entry.description,
                                "row_count": entry.row_count,
                                "access_level": entry.access_level,
                                "updated_at": entry.updated_at,
                            })
                        })
                        .collect();
                    let compact_summary =
                        roboticus_db::hippocampus::compact_summary(db).unwrap_or_default();
                    serde_json::json!({
                        "hippocampus_available": true,
                        "table_count": entries.len(),
                        "owned_table_count": owned_tables.len(),
                        "knowledge_source_count": knowledge_sources.len(),
                        "compact_summary": compact_summary,
                        "owned_tables": owned_tables,
                        "knowledge_sources": knowledge_sources,
                    })
                }
                Err(e) => serde_json::json!({
                    "hippocampus_available": false,
                    "error": format!("failed to load hippocampus: {e}"),
                }),
            }
        } else {
            serde_json::json!({
                "hippocampus_available": false,
                "note": "database not available",
            })
        };
        let info = serde_json::json!({
            "agent_id": ctx.agent_id,
            "agent_name": ctx.agent_name,
            "session_id": ctx.session_id,
            "channel": ctx.channel,
            "workspace_root": ctx.workspace_root.display().to_string(),
            "authority": format!("{:?}", ctx.authority),
            "tool_allowed_paths": tool_paths,
            "storage": storage,
            "sandbox": {
                "filesystem": {
                    "workspace_only": ctx.sandbox.filesystem_workspace_only,
                    "script_fs_confinement": ctx.sandbox.filesystem_script_fs_confinement,
                    "script_allowed_paths": script_paths,
                },
                "skills": {
                    "sandbox_env": ctx.sandbox.skills_sandbox_env,
                    "network_allowed": ctx.sandbox.skills_network_allowed,
                    "skills_dir": ctx.sandbox.skills_dir.display().to_string(),
                },
                "effective_file_tool_roots": {
                    "primary_workspace": ctx.workspace_root.display().to_string(),
                    "extra_tool_allowed_paths": ctx.tool_allowed_paths.iter().map(|p| p.display().to_string()).collect::<Vec<_>>(),
                    "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)."
                }
            },
            "how_to_change_boundaries": {
                "documentation": "docs/CONFIGURATION.md — sections [agent], [security.filesystem], [skills]",
                "toml_keys": [
                    "[agent].workspace — default workspace root for file tools; ROBOTICUS_WORKSPACE for plugins/skills",
                    "[security.filesystem].tool_allowed_paths — allow file tools / bash cwd under extra absolute directories",
                    "[security.filesystem].script_allowed_paths — extra directories for sandboxed skill scripts",
                    "[security.filesystem].workspace_only — when true, keep relative file paths inside workspace",
                    "[security.filesystem].script_fs_confinement — OS-level sandbox for skill scripts",
                    "[skills].sandbox_env — sanitize environment for skill subprocesses",
                    "[skills].network_allowed — outbound network for skill scripts"
                ],
                "after_editing": "Restart roboticus serve (or your Roboticus process) so the new boundaries load.",
                "cli": "roboticus config get <key> | roboticus config set <key> <value> | roboticus check"
            }
        });
        Ok(ToolResult {
            output: serde_json::to_string_pretty(&info).unwrap_or_else(|_| "{}".into()),
            metadata: Some(info),
        })
    }
}

/// Reports memory budget allocation and retrieval tier configuration.
pub struct GetMemoryStatsTool;

#[async_trait]
impl Tool for GetMemoryStatsTool {
    fn name(&self) -> &str {
        "get_memory_stats"
    }

    fn description(&self) -> &str {
        "Returns memory retrieval tier allocations plus live memory health when a database is available, \
         including active vs stale episodic/semantic counts, procedural utilization, and relationship interaction volume"
    }

    fn risk_level(&self) -> RiskLevel {
        RiskLevel::Safe
    }

    fn parameters_schema(&self) -> Value {
        serde_json::json!({
            "type": "object",
            "properties": {},
            "required": []
        })
    }

    async fn execute(
        &self,
        _params: Value,
        ctx: &ToolContext,
    ) -> std::result::Result<ToolResult, ToolError> {
        // Return the default tier budgets; runtime overrides would require
        // access to the live config, which we thread in when available.
        let mut payload = serde_json::json!({
            "tiers": {
                "working": { "budget_pct": 30, "description": "Active conversation context" },
                "episodic": { "budget_pct": 25, "description": "Session digests and summaries" },
                "semantic": { "budget_pct": 20, "description": "Vector-similarity recalled facts" },
                "procedural": { "budget_pct": 15, "description": "How-to knowledge and procedures" },
                "relationship": { "budget_pct": 10, "description": "Entity relationships and graph" },
            },
            "retrieval_method": "5-tier hybrid (FTS5 + vector cosine)",
            "lifecycle_policy": {
                "inactive_states_suppressed_by_default": true,
                "history_queries_can_include_inactive": true,
                "notes": "stale episodic digests and superseded semantic summaries are hidden unless the task explicitly asks for historical or resolved context"
            }
        });
        let live = if let Some(db) = ctx.db.as_ref() {
            match roboticus_db::memory::memory_health_snapshot(db, &ctx.session_id) {
                Ok(snapshot) => serde_json::json!({
                    "available": true,
                    "snapshot": snapshot,
                }),
                Err(e) => serde_json::json!({
                    "available": false,
                    "error": format!("failed to inspect live memory health: {e}"),
                }),
            }
        } else {
            serde_json::json!({
                "available": false,
                "note": "database not available",
            })
        };
        payload["live"] = live;
        Ok(ToolResult {
            output: serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".into()),
            metadata: Some(payload),
        })
    }
}

/// Reports the health of the current delivery channel.
pub struct GetChannelHealthTool;

#[async_trait]
impl Tool for GetChannelHealthTool {
    fn name(&self) -> &str {
        "get_channel_health"
    }

    fn description(&self) -> &str {
        "Returns the health status of the current delivery channel"
    }

    fn risk_level(&self) -> RiskLevel {
        RiskLevel::Safe
    }

    fn parameters_schema(&self) -> Value {
        serde_json::json!({
            "type": "object",
            "properties": {},
            "required": []
        })
    }

    async fn execute(
        &self,
        _params: Value,
        ctx: &ToolContext,
    ) -> std::result::Result<ToolResult, ToolError> {
        let channel = ctx.channel.as_deref().unwrap_or("unknown");
        let health = serde_json::json!({
            "channel": channel,
            "status": "operational",
            "note": "Detailed channel health metrics require a ChannelRouter reference; \
                     basic connectivity confirmed by successful tool invocation.",
        });
        Ok(ToolResult {
            output: serde_json::to_string_pretty(&health).unwrap_or_else(|_| "{}".into()),
            metadata: Some(health),
        })
    }
}

// ── Subagent & Task Introspection ──────────────────────────────────────

/// Returns the status of registered subagents and open tasks.
///
/// Designed to grow over time — future versions may include delegation
/// history, task completion rates, and specialist performance metrics.
pub struct GetSubagentStatusTool;

#[async_trait]
impl Tool for GetSubagentStatusTool {
    fn name(&self) -> &str {
        "get_subagent_status"
    }

    fn description(&self) -> &str {
        "Returns the status of registered subagents (specialists) and open tasks"
    }

    fn risk_level(&self) -> RiskLevel {
        RiskLevel::Safe
    }

    fn parameters_schema(&self) -> Value {
        serde_json::json!({
            "type": "object",
            "properties": {},
            "required": []
        })
    }

    async fn execute(
        &self,
        _params: Value,
        ctx: &ToolContext,
    ) -> std::result::Result<ToolResult, ToolError> {
        let db = match &ctx.db {
            Some(db) => db,
            None => {
                let result = serde_json::json!({
                    "error": "database not available",
                    "subagents": [],
                    "tasks": [],
                });
                return Ok(ToolResult {
                    output: serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".into()),
                    metadata: Some(result),
                });
            }
        };

        // Query subagents
        let subagents = roboticus_db::agents::list_sub_agents(db)
            .unwrap_or_default()
            .into_iter()
            .map(|a| {
                serde_json::json!({
                    "name": a.name,
                    "display_name": a.display_name,
                    "model": a.model,
                    "role": a.role,
                    "enabled": a.enabled,
                    "session_count": a.session_count,
                })
            })
            .collect::<Vec<_>>();

        // Query open tasks
        let tasks = {
            let conn = db.conn();
            conn.prepare(
                "SELECT id, title, status, priority, source, created_at \
                 FROM tasks WHERE status IN ('pending', 'in_progress') \
                 ORDER BY priority DESC, created_at ASC LIMIT 50",
            )
            .ok()
            .map(|mut stmt| {
                stmt.query_map([], |row| {
                    let source_raw: Option<String> = row.get(4)?;
                    Ok(serde_json::json!({
                        "id": row.get::<_, String>(0)?,
                        "title": row.get::<_, String>(1)?,
                        "status": row.get::<_, String>(2)?,
                        "priority": row.get::<_, i64>(3)?,
                        "source": roboticus_db::tasks::normalize_task_source_value(source_raw.as_deref()),
                        "created_at": row.get::<_, String>(5)?,
                    }))
                })
                .inspect_err(|e| tracing::warn!("failed to query tasks: {e}"))
                .ok()
                .map(|rows| rows.filter_map(|r| {
                    r.inspect_err(|e| tracing::warn!("skipping corrupted task row: {e}"))
                        .ok()
                }).collect::<Vec<_>>())
                .unwrap_or_default()
            })
            .unwrap_or_default()
        };

        let result = serde_json::json!({
            "subagents": subagents,
            "subagent_count": subagents.len(),
            "tasks": tasks,
            "open_task_count": tasks.len(),
        });
        Ok(ToolResult {
            output: serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".into()),
            metadata: Some(result),
        })
    }
}