Skip to main content

microclaw_engine/tools/
report_progress.rs

1use std::sync::Arc;
2
3use async_trait::async_trait;
4use serde_json::json;
5
6use super::{schema_object, Tool, ToolResult};
7use crate::config::Config;
8use crate::internal::channels::channel::deliver_and_store_bot_message;
9use crate::internal::channels::channel_adapter::ChannelRegistry;
10use crate::internal::storage::db::{call_blocking, Database};
11use crate::internal::tool_runtime::runtime::auth_context_from_input;
12use microclaw_core::llm_types::ToolDefinition;
13
14/// Lets a running sub-agent push a short progress update to its chat while it
15/// works, like a colleague giving a quick status. Records the update on the run
16/// timeline and (when not throttled) delivers a `📊 [label]: ...` message.
17pub struct ReportProgressTool {
18    config: Config,
19    registry: Arc<ChannelRegistry>,
20    db: Arc<Database>,
21}
22
23impl ReportProgressTool {
24    pub fn new(config: &Config, registry: Arc<ChannelRegistry>, db: Arc<Database>) -> Self {
25        ReportProgressTool {
26            config: config.clone(),
27            registry,
28            db,
29        }
30    }
31}
32
33#[async_trait]
34impl Tool for ReportProgressTool {
35    fn name(&self) -> &str {
36        "report_progress"
37    }
38
39    fn definition(&self) -> ToolDefinition {
40        ToolDefinition {
41            name: "report_progress".into(),
42            description: "Post a short progress update to the chat while you work on a long task (e.g. \"checked 3/5 sources, two left\"). Use it at meaningful milestones so the user gets a colleague-style status; keep each update to one line. Updates are throttled to avoid spam. Only available inside a sub-agent run.".into(),
43            input_schema: schema_object(
44                json!({
45                    "progress": {
46                        "type": "string",
47                        "description": "One-line status update on what's done and what's left."
48                    },
49                    "pct": {
50                        "type": "integer",
51                        "minimum": 0,
52                        "maximum": 100,
53                        "description": "Optional rough completion percentage (0-100)."
54                    }
55                }),
56                &["progress"],
57            ),
58        }
59    }
60
61    async fn execute(&self, input: serde_json::Value) -> ToolResult {
62        let run_id = input
63            .get("__subagent_runtime")
64            .and_then(|v| v.get("run_id"))
65            .and_then(|v| v.as_str())
66            .map(str::to_string);
67        let Some(run_id) = run_id else {
68            return ToolResult::error(
69                "report_progress is only available inside a sub-agent run".into(),
70            );
71        };
72
73        let progress = input
74            .get("progress")
75            .and_then(|v| v.as_str())
76            .unwrap_or("")
77            .trim()
78            .to_string();
79        if progress.is_empty() {
80            return ToolResult::error("Missing required parameter: progress".into());
81        }
82        let pct = input
83            .get("pct")
84            .and_then(|v| v.as_i64())
85            .map(|p| p.clamp(0, 100));
86
87        let Some(auth) = auth_context_from_input(&input) else {
88            return ToolResult::error("report_progress requires caller auth context".into());
89        };
90        let chat_id = auth.caller_chat_id;
91
92        // Record the progress snapshot + timeline event; get the previous
93        // delivery time so we can throttle chat spam.
94        let run_id_for_record = run_id.clone();
95        let progress_for_record = progress.clone();
96        let prev_progress_at = match call_blocking(self.db.clone(), move |db| {
97            db.record_subagent_progress(&run_id_for_record, &progress_for_record)
98        })
99        .await
100        {
101            Ok(v) => v,
102            Err(e) => return ToolResult::error(format!("Failed recording progress: {e}")),
103        };
104
105        // Progress is always recorded on the run timeline (so subagents_list /
106        // standup can show it); delivery to chat is what the toggle controls.
107        if !self.config.subagents.progress_reports
108            || auth.caller_channel == "weixin"
109            || auth.caller_channel.starts_with("weixin.")
110        {
111            return ToolResult::success(
112                json!({"status": "recorded", "delivered": false, "reason": "disabled"}).to_string(),
113            );
114        }
115
116        let min_interval = self.config.subagents.progress_min_interval_secs as i64;
117        let throttled = prev_progress_at
118            .as_deref()
119            .and_then(|ts| chrono::DateTime::parse_from_rfc3339(ts).ok())
120            .map(|prev| {
121                (chrono::Utc::now() - prev.with_timezone(&chrono::Utc)).num_seconds() < min_interval
122            })
123            .unwrap_or(false);
124
125        if throttled {
126            return ToolResult::success(
127                json!({"status": "recorded", "delivered": false, "reason": "throttled"})
128                    .to_string(),
129            );
130        }
131
132        // Look up the human-friendly label for a nicer header.
133        let run_id_for_label = run_id.clone();
134        let label = call_blocking(self.db.clone(), move |db| {
135            db.get_subagent_run(&run_id_for_label, chat_id)
136        })
137        .await
138        .ok()
139        .flatten()
140        .and_then(|r| r.label);
141
142        let header = match label {
143            Some(l) => format!("📊 [{l}]"),
144            None => "📊 progress".to_string(),
145        };
146        let pct_str = pct.map(|p| format!(" ({p}%)")).unwrap_or_default();
147        let message = format!("{header}{pct_str}: {progress}");
148
149        let bot_username = self.config.bot_username_for_channel(&auth.caller_channel);
150        match deliver_and_store_bot_message(
151            self.registry.as_ref(),
152            self.db.clone(),
153            &bot_username,
154            chat_id,
155            &message,
156        )
157        .await
158        {
159            Ok(_) => {
160                ToolResult::success(json!({"status": "recorded", "delivered": true}).to_string())
161            }
162            // The progress is already recorded on the timeline; a delivery hiccup
163            // shouldn't fail the sub-agent's turn.
164            Err(e) => ToolResult::success(
165                json!({"status": "recorded", "delivered": false, "error": e}).to_string(),
166            ),
167        }
168    }
169}