wactorz_agents/
wif_agent.rs

1//! Finance-expert agent — **WIF** (Waldiez Intelligence Finance).
2//!
3//! Tracks expenses, enforces budget limits, and provides financial
4//! calculations and advice on demand. No external APIs required.
5//!
6//! ## Usage (via IO bar)
7//!
8//! ```text
9//! @wif-agent add 25.50 food coffee        → log expense
10//! @wif-agent add 120 rent monthly rent    → log with note
11//! @wif-agent budget food 300              → set category budget
12//! @wif-agent summary                      → all-time spending report
13//! @wif-agent summary today|week|month     → filtered report
14//! @wif-agent balance                      → budget vs actuals
15//! @wif-agent clear food                   → clear category
16//! @wif-agent clear                        → clear everything
17//! @wif-agent calc compound 1000 5 10      → compound interest
18//! @wif-agent calc loan 200000 4.5 30      → monthly mortgage
19//! @wif-agent calc roi 1000 1350           → return on investment
20//! @wif-agent calc tax 80000 25            → tax estimate
21//! @wif-agent tips saving|investing|debt   → financial tips
22//! @wif-agent help                         → this message
23//! ```
24//!
25//! All data is held in memory — restarting the agent resets the ledger.
26
27use anyhow::Result;
28use async_trait::async_trait;
29use std::{
30    collections::HashMap,
31    sync::{Arc, Mutex},
32    time::{SystemTime, UNIX_EPOCH},
33};
34use tokio::sync::mpsc;
35
36use wactorz_core::{Actor, ActorConfig, ActorMetrics, ActorState, EventPublisher, Message};
37
38// ── Data model ─────────────────────────────────────────────────────────────────
39
40#[derive(Clone)]
41struct Expense {
42    amount: f64,
43    category: String,
44    #[allow(dead_code)] // stored for future retrieval (e.g. export, search)
45    note: String,
46    ts_ms: u64,
47}
48
49// ── WifAgent ───────────────────────────────────────────────────────────────────
50
51pub struct WifAgent {
52    config: ActorConfig,
53    state: ActorState,
54    metrics: Arc<ActorMetrics>,
55    mailbox_tx: mpsc::Sender<Message>,
56    mailbox_rx: Option<mpsc::Receiver<Message>>,
57    publisher: Option<EventPublisher>,
58    expenses: Arc<Mutex<Vec<Expense>>>,
59    budgets: Arc<Mutex<HashMap<String, f64>>>,
60}
61
62impl WifAgent {
63    pub fn new(config: ActorConfig) -> Self {
64        let (tx, rx) = mpsc::channel(config.mailbox_capacity);
65        Self {
66            config,
67            state: ActorState::Initializing,
68            metrics: Arc::new(ActorMetrics::new()),
69            mailbox_tx: tx,
70            mailbox_rx: Some(rx),
71            publisher: None,
72            expenses: Arc::new(Mutex::new(Vec::new())),
73            budgets: Arc::new(Mutex::new(HashMap::new())),
74        }
75    }
76
77    pub fn with_publisher(mut self, p: EventPublisher) -> Self {
78        self.publisher = Some(p);
79        self
80    }
81
82    fn now_ms() -> u64 {
83        SystemTime::now()
84            .duration_since(UNIX_EPOCH)
85            .unwrap_or_default()
86            .as_millis() as u64
87    }
88
89    fn reply(&self, content: &str) {
90        if let Some(pub_) = &self.publisher {
91            pub_.publish(
92                wactorz_mqtt::topics::chat(&self.config.id),
93                &serde_json::json!({
94                    "from":        self.config.name,
95                    "to":          "user",
96                    "content":     content,
97                    "timestampMs": Self::now_ms(),
98                }),
99            );
100        }
101    }
102
103    // ── Command handlers ────────────────────────────────────────────────────────
104
105    fn cmd_add(&self, parts: &[&str]) -> String {
106        if parts.is_empty() {
107            return "Usage: `add <amount> [category] [note…]`\n\nExample: `add 25.50 food coffee`"
108                .to_string();
109        }
110
111        let raw = parts[0].trim_start_matches(['$', '€', '£', '¥', '+']);
112        let amount: f64 = match raw.parse() {
113            Ok(v) if v > 0.0 => v,
114            Ok(_) => return "Amount must be positive.".to_string(),
115            Err(_) => return format!("Invalid amount: `{}`", parts[0]),
116        };
117
118        let category = parts.get(1).copied().unwrap_or("misc").to_lowercase();
119        let note = parts.get(2..).map(|p| p.join(" ")).unwrap_or_default();
120
121        let expense = Expense {
122            amount,
123            category: category.clone(),
124            note: note.clone(),
125            ts_ms: Self::now_ms(),
126        };
127        let mut expenses = self.expenses.lock().unwrap();
128        expenses.push(expense);
129
130        let total: f64 = expenses
131            .iter()
132            .filter(|e| e.category == category)
133            .map(|e| e.amount)
134            .sum();
135
136        let budgets = self.budgets.lock().unwrap();
137        let budget_line = if let Some(&budget) = budgets.get(&category) {
138            let pct = (total / budget * 100.0).min(999.0);
139            let remaining = budget - total;
140            let icon = if pct >= 100.0 {
141                "🔴"
142            } else if pct >= 80.0 {
143                "🟡"
144            } else {
145                "🟢"
146            };
147            format!(
148                "\n{icon} **{category}** budget: ${total:.2} / ${budget:.2} ({pct:.0}%) — ${remaining:.2} left"
149            )
150        } else {
151            format!("\n📊 **{category}** running total: ${total:.2}")
152        };
153
154        let note_part = if note.is_empty() {
155            String::new()
156        } else {
157            format!(" _{note}_")
158        };
159        format!("✅ Logged **${amount:.2}** → `{category}`{note_part}{budget_line}")
160    }
161
162    fn cmd_budget(&self, parts: &[&str]) -> String {
163        if parts.len() < 2 {
164            return "Usage: `budget <category> <amount>`\n\nExample: `budget food 300`".to_string();
165        }
166        let category = parts[0].to_lowercase();
167        let raw = parts[1].trim_start_matches(['$', '€', '£', '¥']);
168        let amount: f64 = match raw.parse() {
169            Ok(v) if v >= 0.0 => v,
170            Ok(_) => return "Budget must be ≥ 0.".to_string(),
171            Err(_) => return format!("Invalid amount: `{}`", parts[1]),
172        };
173
174        let mut budgets = self.budgets.lock().unwrap();
175        let verb = if budgets.contains_key(&category) {
176            "Updated"
177        } else {
178            "Set"
179        };
180        budgets.insert(category.clone(), amount);
181
182        let expenses = self.expenses.lock().unwrap();
183        let spent: f64 = expenses
184            .iter()
185            .filter(|e| e.category == category)
186            .map(|e| e.amount)
187            .sum();
188        let pct = if amount > 0.0 {
189            spent / amount * 100.0
190        } else {
191            0.0
192        };
193        let icon = if pct >= 100.0 {
194            "🔴"
195        } else if pct >= 80.0 {
196            "🟡"
197        } else {
198            "🟢"
199        };
200
201        format!(
202            "📋 {verb} budget: **{category}** → **${amount:.2}**\n{icon} Currently at ${spent:.2} ({pct:.0}%)"
203        )
204    }
205
206    fn cmd_summary(&self, period: &str) -> String {
207        let expenses = self.expenses.lock().unwrap();
208        if expenses.is_empty() {
209            return "📭 No expenses recorded yet.\n\nTry: `add 25 food coffee` to get started."
210                .to_string();
211        }
212
213        let now_ms = Self::now_ms();
214        let cutoff_ms: u64 = match period {
215            "today" => now_ms.saturating_sub(86_400_000),
216            "week" => now_ms.saturating_sub(7 * 86_400_000),
217            "month" => now_ms.saturating_sub(30 * 86_400_000),
218            _ => 0,
219        };
220
221        let filtered: Vec<&Expense> = expenses.iter().filter(|e| e.ts_ms >= cutoff_ms).collect();
222        if filtered.is_empty() {
223            return format!("📭 No expenses for `{period}`. Try: `summary all`");
224        }
225
226        let total: f64 = filtered.iter().map(|e| e.amount).sum();
227        let mut by_cat: HashMap<String, f64> = HashMap::new();
228        for e in &filtered {
229            *by_cat.entry(e.category.clone()).or_default() += e.amount;
230        }
231
232        let mut cats: Vec<(String, f64)> = by_cat.into_iter().collect();
233        cats.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
234
235        let budgets = self.budgets.lock().unwrap();
236        let period_label = match period {
237            "today" => "Today",
238            "week" => "This Week",
239            "month" => "This Month",
240            _ => "All Time",
241        };
242
243        let rows: Vec<String> = cats
244            .iter()
245            .map(|(cat, amt)| {
246                let frac = if total > 0.0 { amt / total } else { 0.0 };
247                let bar = Self::mini_bar(frac);
248                let budget_note = budgets
249                    .get(cat)
250                    .map(|&b| {
251                        let pct = amt / b * 100.0;
252                        let icon = if pct >= 100.0 {
253                            "🔴"
254                        } else if pct >= 80.0 {
255                            "🟡"
256                        } else {
257                            "🟢"
258                        };
259                        format!(" {icon} {pct:.0}% of ${b:.0}")
260                    })
261                    .unwrap_or_default();
262                format!("  {bar} **{cat}**: ${amt:.2}{budget_note}")
263            })
264            .collect();
265
266        format!(
267            "**💰 Expense Summary — {period_label}**\n\n{}\n\n**Total: ${total:.2}** ({n} transactions)",
268            rows.join("\n"),
269            n = filtered.len(),
270        )
271    }
272
273    fn mini_bar(fraction: f64) -> &'static str {
274        match (fraction * 5.0).round() as usize {
275            0 => "▁",
276            1 => "▂",
277            2 => "▄",
278            3 => "▆",
279            4 => "▇",
280            _ => "█",
281        }
282    }
283
284    fn cmd_balance(&self) -> String {
285        let budgets = self.budgets.lock().unwrap();
286        if budgets.is_empty() {
287            return "📋 No budgets set yet.\n\nTry: `budget food 300` then `add 25 food coffee`"
288                .to_string();
289        }
290
291        let expenses = self.expenses.lock().unwrap();
292        let mut sorted: Vec<String> = budgets.keys().cloned().collect();
293        sorted.sort();
294
295        let mut rows = Vec::new();
296        let mut total_budget = 0.0f64;
297        let mut total_spent = 0.0f64;
298
299        for cat in &sorted {
300            let budget = budgets[cat];
301            let spent: f64 = expenses
302                .iter()
303                .filter(|e| e.category == *cat)
304                .map(|e| e.amount)
305                .sum();
306            let pct = if budget > 0.0 {
307                (spent / budget * 100.0).min(999.9)
308            } else {
309                0.0
310            };
311            let filled = ((pct / 100.0 * 10.0).min(10.0)) as usize;
312            let bar = format!("[{}{}]", "█".repeat(filled), "░".repeat(10 - filled));
313            let icon = if pct >= 100.0 {
314                "🔴"
315            } else if pct >= 80.0 {
316                "🟡"
317            } else {
318                "🟢"
319            };
320            let remaining = budget - spent;
321            let rem_str = if remaining >= 0.0 {
322                format!("${remaining:.2} left")
323            } else {
324                format!("${:.2} over", remaining.abs())
325            };
326            rows.push(format!(
327                "{icon} **{cat}**: {bar} ${spent:.2} / ${budget:.2} ({pct:.0}%) — {rem_str}"
328            ));
329            total_budget += budget;
330            total_spent += spent;
331        }
332
333        let overall_pct = if total_budget > 0.0 {
334            (total_spent / total_budget * 100.0).min(999.9)
335        } else {
336            0.0
337        };
338        let overall_icon = if overall_pct >= 100.0 {
339            "🔴"
340        } else if overall_pct >= 80.0 {
341            "🟡"
342        } else {
343            "🟢"
344        };
345        rows.push(String::new());
346        rows.push(format!(
347            "{overall_icon} **TOTAL**: ${total_spent:.2} / ${total_budget:.2} ({overall_pct:.0}%)"
348        ));
349
350        format!("**📊 Budget Balance**\n\n{}", rows.join("\n"))
351    }
352
353    fn cmd_clear(&self, category: Option<&str>) -> String {
354        let mut expenses = self.expenses.lock().unwrap();
355        match category {
356            None => {
357                let n = expenses.len();
358                expenses.clear();
359                format!("🗑 Cleared all {n} expenses.")
360            }
361            Some(cat) => {
362                let before = expenses.len();
363                expenses.retain(|e| e.category != cat);
364                let removed = before - expenses.len();
365                format!("🗑 Cleared {removed} expenses from `{cat}`.")
366            }
367        }
368    }
369
370    fn cmd_calc(&self, parts: &[&str]) -> String {
371        match parts.first().copied().unwrap_or("") {
372            "compound" | "ci" => {
373                if parts.len() < 4 {
374                    return "Usage: `calc compound <principal> <rate%> <years>`\n\nExample: `calc compound 10000 7 20`".to_string();
375                }
376                let p: f64 = parts[1].trim_start_matches('$').parse().unwrap_or(0.0);
377                let r: f64 = parts[2].trim_end_matches('%').parse::<f64>().unwrap_or(0.0) / 100.0;
378                let t: f64 = parts[3].parse().unwrap_or(0.0);
379                if p <= 0.0 || t <= 0.0 {
380                    return "Principal and years must be positive.".to_string();
381                }
382                let n = 12.0; // monthly compounding
383                let fv = p * (1.0 + r / n).powf(n * t);
384                let int = fv - p;
385                let rate_pct = r * 100.0;
386                let gain_pct = if p > 0.0 { int / p * 100.0 } else { 0.0 };
387                format!(
388                    "**📈 Compound Interest (monthly)**\n\nPrincipal : ${p:.2}\nRate      : {rate_pct:.2}% p.a.\nTerm      : {t} years\n\n→ Future Value  : **${fv:.2}**\n→ Interest Earned: **${int:.2}** ({gain_pct:.0}% gain)"
389                )
390            }
391
392            "loan" | "mortgage" => {
393                if parts.len() < 4 {
394                    return "Usage: `calc loan <principal> <rate%> <years>`\n\nExample: `calc loan 300000 4.5 30`".to_string();
395                }
396                let p: f64 = parts[1].trim_start_matches('$').parse().unwrap_or(0.0);
397                let r: f64 =
398                    parts[2].trim_end_matches('%').parse::<f64>().unwrap_or(0.0) / 100.0 / 12.0;
399                let n: f64 = parts[3].parse::<f64>().unwrap_or(0.0) * 12.0;
400                if p <= 0.0 || n <= 0.0 {
401                    return "Principal and years must be positive.".to_string();
402                }
403                let (monthly, total, interest) = if r == 0.0 {
404                    let m = p / n;
405                    (m, p, 0.0)
406                } else {
407                    let m = p * r * (1.0 + r).powf(n) / ((1.0 + r).powf(n) - 1.0);
408                    (m, m * n, m * n - p)
409                };
410                let rate_pct: f64 = parts[2].trim_end_matches('%').parse().unwrap_or(0.0);
411                format!(
412                    "**🏠 Loan / Mortgage Calculator**\n\nPrincipal : ${p:.2}\nRate      : {rate_pct:.2}% p.a.\nTerm      : {} years\n\n→ Monthly Payment : **${monthly:.2}**\n→ Total Repaid    : **${total:.2}**\n→ Total Interest  : **${interest:.2}**",
413                    parts[3],
414                )
415            }
416
417            "roi" => {
418                if parts.len() < 3 {
419                    return "Usage: `calc roi <initial> <final>`\n\nExample: `calc roi 5000 7500`"
420                        .to_string();
421                }
422                let initial: f64 = parts[1].trim_start_matches('$').parse().unwrap_or(0.0);
423                let final_val: f64 = parts[2].trim_start_matches('$').parse().unwrap_or(0.0);
424                if initial == 0.0 {
425                    return "Initial value cannot be zero.".to_string();
426                }
427                let roi = (final_val - initial) / initial * 100.0;
428                let gain = final_val - initial;
429                let icon = if gain >= 0.0 { "📈" } else { "📉" };
430                format!(
431                    "{icon} **Return on Investment**\n\nInitial : ${initial:.2}\nFinal   : ${final_val:.2}\nGain    : ${gain:+.2}\n\n→ ROI: **{roi:+.2}%**"
432                )
433            }
434
435            "tax" => {
436                if parts.len() < 2 {
437                    return "Usage: `calc tax <income> [rate%]`\n\nExample: `calc tax 75000 25`"
438                        .to_string();
439                }
440                let income: f64 = parts[1].trim_start_matches('$').parse().unwrap_or(0.0);
441                let rate: f64 = parts
442                    .get(2)
443                    .and_then(|s| s.trim_end_matches('%').parse().ok())
444                    .unwrap_or(25.0);
445                let tax = income * rate / 100.0;
446                let net = income - tax;
447                format!(
448                    "**💸 Tax Estimate**\n\nGross Income : ${income:.2}\nTax Rate     : {rate:.1}%\n\n→ Tax         : **${tax:.2}**\n→ Net Income  : **${net:.2}**\n\n_Note: simplified estimate — consult a tax professional._"
449                )
450            }
451
452            _ => "**calc** subcommands:\n\n\
453                 ```\n\
454                 calc compound <principal> <rate%> <years>  — compound interest\n\
455                 calc loan <principal> <rate%> <years>       — loan / mortgage\n\
456                 calc roi <initial> <final>                  — return on investment\n\
457                 calc tax <income> [rate%]                   — tax estimate (default 25%)\n\
458                 ```"
459            .to_string(),
460        }
461    }
462
463    fn cmd_tips(&self, topic: &str) -> String {
464        match topic {
465            "saving" | "save" => "**💡 Saving Tips**\n\n\
466                 1. **50/30/20 rule** — 50% needs · 30% wants · 20% savings\n\
467                 2. **Pay yourself first** — automate a transfer on payday\n\
468                 3. **Emergency fund** — target 3–6 months of expenses\n\
469                 4. **Cut subscriptions** — review monthly recurring charges\n\
470                 5. **Track everything** — use `add <amount> <category>` to log expenses\n\n\
471                 _Try: `budget food 300` then `add 25 food coffee` to start tracking._"
472                .to_string(),
473            "investing" | "invest" => "**📈 Investing Tips**\n\n\
474                 1. **Start early** — compound interest is exponential; time matters most\n\
475                 2. **Diversify** — spread across asset classes, geographies\n\
476                 3. **Low-cost index funds** — outperform most active funds long-term\n\
477                 4. **Dollar-cost average** — invest fixed amounts on a schedule\n\
478                 5. **Don't time the market** — time *in* the market beats timing it\n\n\
479                 _Try: `calc compound 10000 8 30` to see long-term growth._"
480                .to_string(),
481            "debt" => "**⚡ Debt Elimination Tips**\n\n\
482                 1. **Avalanche method** — pay highest-interest debt first (saves most money)\n\
483                 2. **Snowball method** — pay smallest balance first (psychological wins)\n\
484                 3. **Never miss minimums** — late fees and credit damage compound fast\n\
485                 4. **Refinance wisely** — lower rates can cut years off repayment\n\
486                 5. **No new debt** — stop accumulating while paying off existing debt\n\n\
487                 _Try: `calc loan 20000 18.9 5` to see credit-card debt cost._"
488                .to_string(),
489            "budget" => "**📋 Budgeting Tips**\n\n\
490                 1. **Zero-based budget** — give every dollar a job\n\
491                 2. **Set category limits** — use `budget <category> <amount>`\n\
492                 3. **Check balance weekly** — use `balance` to see spend vs budget\n\
493                 4. **Review monthly** — adjust budgets to reflect actual life\n\
494                 5. **Include fun money** — rigid budgets fail; build in discretionary\n\n\
495                 _Use `summary month` for a monthly breakdown._"
496                .to_string(),
497            _ => "**WIF Financial Tips** — pick a topic:\n\n\
498                 ```\n\
499                 tips saving     — spending reduction strategies\n\
500                 tips investing  — growing wealth over time\n\
501                 tips debt       — paying off debt efficiently\n\
502                 tips budget     — budgeting best practices\n\
503                 ```"
504            .to_string(),
505        }
506    }
507
508    fn dispatch(&self, text: &str) -> String {
509        let arg = text.strip_prefix("@wif-agent").unwrap_or(text).trim();
510
511        let parts: Vec<&str> = arg.split_whitespace().collect();
512        let cmd = parts.first().copied().unwrap_or("help");
513
514        match cmd {
515            "add" => self.cmd_add(&parts[1..]),
516            "budget" => self.cmd_budget(&parts[1..]),
517            "summary" => self.cmd_summary(parts.get(1).copied().unwrap_or("all")),
518            "balance" => self.cmd_balance(),
519            "clear" => self.cmd_clear(parts.get(1).copied()),
520            "calc" => self.cmd_calc(&parts[1..]),
521            "tips" => self.cmd_tips(parts.get(1).copied().unwrap_or("")),
522            "help" | "" => "**WIF — Finance Expert** 💹\n\
523                 _Waldiez Intelligence Finance_\n\n\
524                 ```\n\
525                 add <amount> [category] [note]       log an expense\n\
526                 budget <category> <amount>           set budget limit\n\
527                 summary [today|week|month|all]       spending report\n\
528                 balance                              budget vs actuals\n\
529                 clear [category]                     reset expenses\n\
530                 calc compound <p> <rate%> <years>    compound interest\n\
531                 calc loan <p> <rate%> <years>        loan / mortgage\n\
532                 calc roi <initial> <final>            return on invest\n\
533                 calc tax <income> [rate%]             tax estimate\n\
534                 tips [saving|investing|debt|budget]  financial advice\n\
535                 help                                 this message\n\
536                 ```"
537            .to_string(),
538            _ => format!("Unknown command: `{cmd}`. Type `help` for the full command list."),
539        }
540    }
541}
542
543// ── Actor implementation ────────────────────────────────────────────────────────
544
545#[async_trait]
546impl Actor for WifAgent {
547    fn id(&self) -> String {
548        self.config.id.clone()
549    }
550    fn name(&self) -> &str {
551        &self.config.name
552    }
553    fn state(&self) -> ActorState {
554        self.state.clone()
555    }
556    fn metrics(&self) -> Arc<ActorMetrics> {
557        Arc::clone(&self.metrics)
558    }
559    fn mailbox(&self) -> mpsc::Sender<Message> {
560        self.mailbox_tx.clone()
561    }
562    fn is_protected(&self) -> bool {
563        self.config.protected
564    }
565
566    async fn on_start(&mut self) -> Result<()> {
567        self.state = ActorState::Running;
568        if let Some(pub_) = &self.publisher {
569            pub_.publish(
570                wactorz_mqtt::topics::spawn(&self.config.id),
571                &serde_json::json!({
572                    "agentId":     self.config.id,
573                    "agentName":   self.config.name,
574                    "agentType":   "financier",
575                    "timestampMs": Self::now_ms(),
576                }),
577            );
578        }
579        Ok(())
580    }
581
582    async fn handle_message(&mut self, message: Message) -> Result<()> {
583        use wactorz_core::message::MessageType;
584
585        let content = match &message.payload {
586            MessageType::Text { content } => content.trim().to_string(),
587            MessageType::Task { description, .. } => description.trim().to_string(),
588            _ => return Ok(()),
589        };
590
591        let reply = self.dispatch(&content);
592        self.reply(&reply);
593        Ok(())
594    }
595
596    async fn on_heartbeat(&mut self) -> Result<()> {
597        if let Some(pub_) = &self.publisher {
598            pub_.publish(
599                wactorz_mqtt::topics::heartbeat(&self.config.id),
600                &serde_json::json!({
601                    "agentId":     self.config.id,
602                    "agentName":   self.config.name,
603                    "state":       self.state,
604                    "timestampMs": Self::now_ms(),
605                }),
606            );
607        }
608        Ok(())
609    }
610
611    async fn run(&mut self) -> Result<()> {
612        self.on_start().await?;
613
614        let mut rx = self
615            .mailbox_rx
616            .take()
617            .ok_or_else(|| anyhow::anyhow!("WifAgent already running"))?;
618
619        let mut hb = tokio::time::interval(std::time::Duration::from_secs(
620            self.config.heartbeat_interval_secs,
621        ));
622        hb.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
623
624        loop {
625            tokio::select! {
626                biased;
627                msg = rx.recv() => match msg {
628                    None    => break,
629                    Some(m) => {
630                        self.metrics.record_received();
631                        if let wactorz_core::message::MessageType::Command {
632                            command: wactorz_core::message::ActorCommand::Stop,
633                        } = &m.payload { break; }
634                        match self.handle_message(m).await {
635                            Ok(_)  => self.metrics.record_processed(),
636                            Err(e) => {
637                                tracing::error!("[{}] {e}", self.config.name);
638                                self.metrics.record_failed();
639                            }
640                        }
641                    }
642                },
643                _ = hb.tick() => {
644                    self.metrics.record_heartbeat();
645                    if let Err(e) = self.on_heartbeat().await {
646                        tracing::error!("[{}] heartbeat: {e}", self.config.name);
647                    }
648                }
649            }
650        }
651
652        self.state = ActorState::Stopped;
653        self.on_stop().await
654    }
655}