Skip to main content

wm_tools/expansion/
tools_mgmt.rs

1//! Tools management — usage_report, effectiveness_report, retire.
2
3#![forbid(unsafe_code)]
4
5use std::sync::Arc;
6
7use async_trait::async_trait;
8
9use serde_json::{Value, json};
10use wm_core::{Context, EffectRow, Gana, Tool, ToolStats};
11use wm_dispatch::ToolRegistry;
12
13/// `tools.usage_report` — registry-wide usage ranking from live dispatch stats.
14///
15/// Unlike `tools.effectiveness_report` (which only sees its own counters),
16/// this tool holds a snapshot of the tool registry. Tool `Arc`s are shared
17/// across registries, so the per-tool `ToolStats` atomics it reads are the
18/// same ones the dispatch pipeline updates — the report is always live.
19pub struct ToolsUsageReportTool {
20    registry: Arc<ToolRegistry>,
21    stats: ToolStats,
22    effects: EffectRow,
23}
24
25impl ToolsUsageReportTool {
26    #[must_use]
27    pub fn new(registry: Arc<ToolRegistry>) -> Self {
28        Self {
29            registry,
30            stats: ToolStats::default(),
31            effects: EffectRow::pure(),
32        }
33    }
34}
35
36#[async_trait]
37impl Tool for ToolsUsageReportTool {
38    fn name(&self) -> &str {
39        "tools.usage_report"
40    }
41    fn gana(&self) -> Gana {
42        Gana::Ghost
43    }
44    fn effects(&self) -> &EffectRow {
45        &self.effects
46    }
47    fn description(&self) -> &str {
48        "Rank all registered tools by usage from live dispatch stats: calls, success rate, latency, and retirement candidates"
49    }
50    fn input_schema(&self) -> Value {
51        json!({
52            "type": "object",
53            "properties": {
54                "sort": {
55                    "type": "string",
56                    "enum": ["calls", "effectiveness", "latency"],
57                    "description": "Sort key (default: calls, descending; effectiveness ascending — worst first; latency descending — slowest first)"
58                },
59                "limit": {
60                    "type": "integer",
61                    "description": "Maximum number of tools to return (default: 25, 0 = all)"
62                },
63                "min_calls": {
64                    "type": "integer",
65                    "description": "Only include tools with at least this many calls (default: 0)"
66                },
67                "retire_candidates_only": {
68                    "type": "boolean",
69                    "description": "Only include tools flagged for retirement (>= 10 calls, effectiveness < 0.2)"
70                }
71            }
72        })
73    }
74    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
75        let sort = args.get("sort").and_then(Value::as_str).unwrap_or("calls");
76        let limit = args
77            .get("limit")
78            .and_then(Value::as_u64)
79            .map_or(25, |l| l as usize);
80        let min_calls = args.get("min_calls").and_then(Value::as_u64).unwrap_or(0);
81        let retire_only = args
82            .get("retire_candidates_only")
83            .and_then(Value::as_bool)
84            .unwrap_or(false);
85
86        let tools = self.registry.all_ref();
87        let total_registered = tools.len();
88
89        let mut entries: Vec<(u64, f32, u64, Value)> = tools
90            .iter()
91            .filter_map(|t| {
92                let snap = t.stats().snapshot();
93                if snap.call_count < min_calls {
94                    return None;
95                }
96                let should_retire = t.stats().should_retire(10, 0.2);
97                if retire_only && !should_retire {
98                    return None;
99                }
100                let entry = json!({
101                    "name": t.name(),
102                    "gana": format!("{:?}", t.gana()),
103                    "call_count": snap.call_count,
104                    "success_count": snap.success_count,
105                    "effectiveness": (f64::from(snap.effectiveness) * 100.0).round() / 100.0,
106                    "p50_latency_ms": (snap.p50_latency_ns as f64 / 1_000_000.0 * 100.0).round() / 100.0,
107                    "peak_latency_ms": (snap.peak_latency_ns as f64 / 1_000_000.0 * 100.0).round() / 100.0,
108                    "last_used_unix": snap.last_used_unix,
109                    "should_retire": should_retire,
110                });
111                Some((snap.call_count, snap.effectiveness, snap.p50_latency_ns, entry))
112            })
113            .collect();
114
115        match sort {
116            // Worst effectiveness first — retirement review order.
117            "effectiveness" => entries.sort_by(|a, b| {
118                a.1.partial_cmp(&b.1)
119                    .unwrap_or(std::cmp::Ordering::Equal)
120                    .then(b.0.cmp(&a.0))
121            }),
122            // Slowest first — latency hot-spot order.
123            "latency" => entries.sort_by_key(|x| std::cmp::Reverse(x.2)),
124            // Default: most-called first — usage-distribution order.
125            _ => entries.sort_by_key(|x| std::cmp::Reverse(x.0)),
126        }
127
128        let matched = entries.len();
129        let used_count = tools
130            .iter()
131            .filter(|t| {
132                t.stats()
133                    .call_count
134                    .load(std::sync::atomic::Ordering::Relaxed)
135                    > 0
136            })
137            .count();
138        let report: Vec<Value> = entries
139            .into_iter()
140            .map(|(_, _, _, e)| e)
141            .take(if limit == 0 { usize::MAX } else { limit })
142            .collect();
143
144        Ok(json!({
145            "status": "success",
146            "sort": sort,
147            "total_registered": total_registered,
148            "total_used": used_count,
149            "matched": matched,
150            "tools": report,
151        }))
152    }
153    fn stats(&self) -> &ToolStats {
154        &self.stats
155    }
156}
157
158pub struct ToolsEffectivenessReportTool {
159    stats: ToolStats,
160    effects: EffectRow,
161}
162
163impl ToolsEffectivenessReportTool {
164    #[must_use]
165    pub fn new() -> Self {
166        Self {
167            stats: ToolStats::default(),
168            effects: EffectRow::pure(),
169        }
170    }
171}
172
173impl Default for ToolsEffectivenessReportTool {
174    fn default() -> Self {
175        Self::new()
176    }
177}
178
179#[async_trait]
180impl Tool for ToolsEffectivenessReportTool {
181    fn name(&self) -> &str {
182        "tools.effectiveness_report"
183    }
184    fn gana(&self) -> Gana {
185        Gana::Ghost
186    }
187    fn effects(&self) -> &EffectRow {
188        &self.effects
189    }
190    fn description(&self) -> &str {
191        "Report on tool effectiveness from dispatch stats"
192    }
193    async fn call(&self, _ctx: &mut Context, _args: Value) -> wm_core::Result<Value> {
194        let total = self
195            .stats
196            .call_count
197            .load(std::sync::atomic::Ordering::Relaxed);
198        let successes = self
199            .stats
200            .success_count
201            .load(std::sync::atomic::Ordering::Relaxed);
202        let failures = total.saturating_sub(successes);
203        let effectiveness = if total > 0 {
204            successes as f32 / total as f32
205        } else {
206            1.0
207        };
208        Ok(json!({
209            "status": "success",
210            "total_calls": total,
211            "successes": successes,
212            "failures": failures,
213            "effectiveness": (effectiveness * 100.0).round() / 100.0,
214        }))
215    }
216    fn stats(&self) -> &ToolStats {
217        &self.stats
218    }
219}
220
221/// `tools.retire` — check if a tool should be retired based on effectiveness.
222pub struct ToolsRetireTool {
223    stats: ToolStats,
224    effects: EffectRow,
225}
226
227impl ToolsRetireTool {
228    #[must_use]
229    pub fn new() -> Self {
230        Self {
231            stats: ToolStats::default(),
232            effects: EffectRow::pure(),
233        }
234    }
235}
236
237impl Default for ToolsRetireTool {
238    fn default() -> Self {
239        Self::new()
240    }
241}
242
243#[async_trait]
244impl Tool for ToolsRetireTool {
245    fn name(&self) -> &str {
246        "tools.retire"
247    }
248    fn gana(&self) -> Gana {
249        Gana::Ghost
250    }
251    fn effects(&self) -> &EffectRow {
252        &self.effects
253    }
254    fn description(&self) -> &str {
255        "Check if a tool should be retired based on effectiveness threshold"
256    }
257    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
258        let tool_name = args
259            .get("tool")
260            .and_then(|v| v.as_str())
261            .unwrap_or("unknown");
262        let threshold = args
263            .get("threshold")
264            .and_then(serde_json::Value::as_f64)
265            .unwrap_or(0.10) as f32;
266        let effectiveness = args
267            .get("effectiveness")
268            .and_then(serde_json::Value::as_f64)
269            .unwrap_or(1.0) as f32;
270        let should_retire = effectiveness < threshold;
271        Ok(json!({
272            "status": "success",
273            "tool": tool_name,
274            "effectiveness": effectiveness,
275            "threshold": threshold,
276            "should_retire": should_retire,
277            "recommendation": if should_retire { "retire" } else { "keep" },
278        }))
279    }
280    fn stats(&self) -> &ToolStats {
281        &self.stats
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288    use std::time::Duration;
289    use wm_dispatch::ToolRegistryBuilder;
290
291    struct StubTool {
292        name: &'static str,
293        stats: ToolStats,
294        effects: EffectRow,
295    }
296
297    impl StubTool {
298        fn new(name: &'static str) -> Self {
299            Self {
300                name,
301                stats: ToolStats::default(),
302                effects: EffectRow::pure(),
303            }
304        }
305    }
306
307    #[async_trait]
308    impl Tool for StubTool {
309        fn name(&self) -> &str {
310            self.name
311        }
312        fn gana(&self) -> Gana {
313            Gana::Ghost
314        }
315        fn effects(&self) -> &EffectRow {
316            &self.effects
317        }
318        async fn call(&self, _ctx: &mut Context, _args: Value) -> wm_core::Result<Value> {
319            Ok(json!({"status": "success"}))
320        }
321        fn stats(&self) -> &ToolStats {
322            &self.stats
323        }
324    }
325
326    fn registry_with_usage() -> Arc<ToolRegistry> {
327        let hot = Arc::new(StubTool::new("stub.hot"));
328        for _ in 0..20 {
329            hot.stats
330                .record_success(Duration::from_millis(1), Duration::from_millis(1));
331        }
332        let failing = Arc::new(StubTool::new("stub.failing"));
333        for _ in 0..2 {
334            failing
335                .stats
336                .record_success(Duration::from_millis(1), Duration::from_millis(1));
337        }
338        for _ in 0..13 {
339            failing.stats.record_failure(Duration::from_millis(1));
340        }
341        let unused = Arc::new(StubTool::new("stub.unused"));
342
343        let mut builder = ToolRegistryBuilder::new();
344        builder.register(hot);
345        builder.register(failing);
346        builder.register(unused);
347        Arc::new(builder.build())
348    }
349
350    #[tokio::test]
351    async fn usage_report_ranks_by_calls() {
352        let tool = ToolsUsageReportTool::new(registry_with_usage());
353        let mut ctx = Context::default();
354        let out = tool.call(&mut ctx, json!({})).await.unwrap();
355
356        assert_eq!(out["status"], "success");
357        assert_eq!(out["total_registered"], 3);
358        assert_eq!(out["total_used"], 2);
359        let tools = out["tools"].as_array().unwrap();
360        assert_eq!(tools.len(), 3);
361        assert_eq!(tools[0]["name"], "stub.hot");
362        assert_eq!(tools[0]["call_count"], 20);
363        assert_eq!(tools[1]["name"], "stub.failing");
364    }
365
366    #[tokio::test]
367    async fn usage_report_flags_retirement_candidates() {
368        let tool = ToolsUsageReportTool::new(registry_with_usage());
369        let mut ctx = Context::default();
370        let out = tool
371            .call(&mut ctx, json!({"retire_candidates_only": true}))
372            .await
373            .unwrap();
374
375        let tools = out["tools"].as_array().unwrap();
376        assert_eq!(tools.len(), 1);
377        assert_eq!(tools[0]["name"], "stub.failing");
378        assert_eq!(tools[0]["should_retire"], true);
379    }
380
381    #[tokio::test]
382    async fn usage_report_min_calls_and_effectiveness_sort() {
383        let tool = ToolsUsageReportTool::new(registry_with_usage());
384        let mut ctx = Context::default();
385        let out = tool
386            .call(&mut ctx, json!({"min_calls": 1, "sort": "effectiveness"}))
387            .await
388            .unwrap();
389
390        let tools = out["tools"].as_array().unwrap();
391        assert_eq!(tools.len(), 2, "unused tool filtered by min_calls");
392        assert_eq!(
393            tools[0]["name"], "stub.failing",
394            "worst effectiveness first"
395        );
396    }
397}