microclaw_engine/tools/
insights.rs1use 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
10pub 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 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}