Skip to main content

microclaw_engine/tools/
insights.rs

1use async_trait::async_trait;
2use serde_json::{json, Value};
3use std::sync::Arc;
4
5use crate::internal::storage::db::{call_blocking, Database};
6use microclaw_core::llm_types::ToolDefinition;
7
8use super::{auth_context_from_input, authorize_chat_access, schema_object, Tool, ToolResult};
9
10/// Insights tool — compact usage / activity summary over a trailing window.
11///
12/// Aggregates `llm_usage_logs` and message counts to produce a markdown
13/// summary of recent activity. Use-cases: daily report, spend check,
14/// "which model did I burn tokens on this week". Modeled after
15/// hermes-agent's `/insights [days]` command.
16///
17/// Access model: scoped to caller's chat by default (like `session_search`);
18/// control chats may pass `all_chats: true` for a tenant-wide view.
19pub struct InsightsTool {
20    db: Arc<Database>,
21}
22
23impl InsightsTool {
24    pub fn new(db: Arc<Database>) -> Self {
25        Self { db }
26    }
27}
28
29#[async_trait]
30impl Tool for InsightsTool {
31    fn name(&self) -> &str {
32        "insights"
33    }
34
35    fn definition(&self) -> ToolDefinition {
36        ToolDefinition {
37            name: self.name().into(),
38            description: "Summarize recent chat activity and LLM usage — requests, \
39                input/output tokens, breakdown by model. Use for `/insights` style \
40                reports, spend checks, or end-of-day digests. Scoped to the \
41                caller's chat by default; `all_chats: true` requires a control chat."
42                .into(),
43            input_schema: schema_object(
44                json!({
45                    "days": {
46                        "type": "integer",
47                        "description": "Trailing window in days (default 7, max 90)."
48                    },
49                    "chat_id": {
50                        "type": "integer",
51                        "description": "Optional explicit chat (requires access)."
52                    },
53                    "all_chats": {
54                        "type": "boolean",
55                        "description": "Control-chat opt-in for tenant-wide aggregation."
56                    }
57                }),
58                &[],
59            ),
60        }
61    }
62
63    async fn execute(&self, input: Value) -> ToolResult {
64        let Some(auth) = auth_context_from_input(&input) else {
65            return ToolResult::error("insights requires an auth context (caller_chat_id)".into());
66        };
67        let days = input
68            .get("days")
69            .and_then(|v| v.as_u64())
70            .unwrap_or(7)
71            .clamp(1, 90);
72        let all_chats = input
73            .get("all_chats")
74            .and_then(|v| v.as_bool())
75            .unwrap_or(false);
76        let explicit_chat = input.get("chat_id").and_then(|v| v.as_i64());
77
78        let scope: Option<i64> = if all_chats {
79            if !auth.is_control_chat() {
80                return ToolResult::error("all_chats=true requires a control chat".into());
81            }
82            None
83        } else {
84            let target = explicit_chat.unwrap_or(auth.caller_chat_id);
85            if let Err(e) = authorize_chat_access(&input, target) {
86                return ToolResult::error(e);
87            }
88            Some(target)
89        };
90
91        let since = (chrono::Utc::now() - chrono::Duration::days(days as i64)).to_rfc3339();
92        let db = self.db.clone();
93        let since_owned = since.clone();
94        let fetch = call_blocking(db.clone(), move |d| {
95            let summary = d.get_llm_usage_summary_since(scope, Some(&since_owned))?;
96            let by_model = d.get_llm_usage_by_model(scope, Some(&since_owned), Some(20))?;
97            Ok::<_, microclaw_core::error::MicroClawError>((summary, by_model))
98        })
99        .await;
100
101        let (summary, by_model) = match fetch {
102            Ok(v) => v,
103            Err(e) => return ToolResult::error(format!("insights query failed: {e}")),
104        };
105
106        let scope_label = match scope {
107            Some(cid) => format!("chat {cid}"),
108            None => "all chats".to_string(),
109        };
110        let mut lines = Vec::new();
111        lines.push(format!("# Insights — last {days} day(s), {scope_label}"));
112        lines.push(String::new());
113        lines.push(format!("- requests: **{}**", summary.requests));
114        lines.push(format!("- input tokens:  **{}**", summary.input_tokens));
115        lines.push(format!("- output tokens: **{}**", summary.output_tokens));
116        lines.push(format!("- total tokens:  **{}**", summary.total_tokens));
117        if let Some(ts) = summary.last_request_at.as_deref() {
118            lines.push(format!("- last request: {ts}"));
119        }
120        if !by_model.is_empty() {
121            lines.push(String::new());
122            lines.push("## By model".into());
123            for entry in &by_model {
124                lines.push(format!(
125                    "- `{}`: {} req, {} in + {} out = {} tok",
126                    entry.model,
127                    entry.requests,
128                    entry.input_tokens,
129                    entry.output_tokens,
130                    entry.total_tokens,
131                ));
132            }
133        }
134
135        // Crash visibility: supervised background loops that panicked and
136        // were restarted this process lifetime. Empty = healthy.
137        let restarts = crate::supervision::restart_counts();
138        if !restarts.is_empty() {
139            lines.push(String::new());
140            lines.push("⚠️ Background task restarts since process start:".to_string());
141            for (name, count) in restarts {
142                lines.push(format!("- {name}: {count} restart(s) after panic"));
143            }
144        }
145
146        ToolResult::success(lines.join("\n"))
147    }
148}