Skip to main content

microclaw_engine/config/
governance.rs

1use super::*;
2
3/// Opt-in post-edit self-recheck (one review pass after file-modifying
4/// turns, before the reply finalizes).
5#[derive(Clone, Debug, Default, Serialize, Deserialize)]
6pub struct SelfRecheckConfig {
7    #[serde(default)]
8    pub enabled: bool,
9    /// Custom prompt template; `{{USER}}` and `{{RESULT}}` placeholders are
10    /// replaced with the original request and the draft reply.
11    #[serde(default)]
12    pub prompt: Option<String>,
13}
14
15pub(crate) fn default_token_budget_exempt_control_chats() -> bool {
16    true
17}
18
19/// Per-chat token spend cap. Counters Hermes-style "week-3 bill" drift:
20/// once a chat's rolling-24h total (input+output, all request kinds) hits
21/// `daily_per_chat`, new turns are refused with a notice until usage rolls
22/// out of the window. 0 (default) = unlimited; control chats exempt by
23/// default so operators can always reach the bot.
24#[derive(Clone, Debug, Serialize, Deserialize)]
25pub struct TokenBudgetConfig {
26    /// Total tokens (input+output) allowed per chat per rolling 24h. 0 = off.
27    #[serde(default)]
28    pub daily_per_chat: i64,
29    #[serde(default = "default_token_budget_exempt_control_chats")]
30    pub exempt_control_chats: bool,
31}
32
33impl Default for TokenBudgetConfig {
34    fn default() -> Self {
35        Self {
36            daily_per_chat: 0,
37            exempt_control_chats: default_token_budget_exempt_control_chats(),
38        }
39    }
40}
41
42impl TokenBudgetConfig {
43    /// True when a chat that has already spent `used` tokens in the window
44    /// must be refused a new turn.
45    pub fn blocks(&self, is_control_chat: bool, used: i64) -> bool {
46        if self.daily_per_chat <= 0 {
47            return false;
48        }
49        if self.exempt_control_chats && is_control_chat {
50            return false;
51        }
52        used >= self.daily_per_chat
53    }
54}
55
56pub(crate) fn default_alerts_interval_secs() -> u64 {
57    60
58}
59
60pub(crate) fn default_alerts_cooldown_secs() -> u64 {
61    900
62}
63
64pub(crate) fn default_alerts_restart_storm_threshold() -> u64 {
65    5
66}
67
68/// Opt-in operational webhook alerts. When enabled, a supervised loop
69/// polls runtime health every `interval_secs` and POSTs a JSON alert to
70/// `webhook_url` when a condition trips: scheduler DLQ growth, provider
71/// down (circuit breaker open / repeated failures), token-budget
72/// exhaustion, or a supervised-loop restart storm. OFF by default; the
73/// webhook URL participates in the configured-endpoint egress policy.
74#[derive(Clone, Debug, Serialize, Deserialize)]
75pub struct AlertsConfig {
76    #[serde(default)]
77    pub enabled: bool,
78    /// Webhook that receives alert POSTs (JSON body: class, message,
79    /// generated_at).
80    #[serde(default)]
81    pub webhook_url: String,
82    /// Seconds between health polls. Default: 60 (min 10).
83    #[serde(default = "default_alerts_interval_secs")]
84    pub interval_secs: u64,
85    /// Minimum seconds between two alerts of the same class. Default: 900.
86    #[serde(default = "default_alerts_cooldown_secs")]
87    pub cooldown_secs: u64,
88    /// Supervised-loop restarts within one poll interval that count as a
89    /// storm. Default: 5.
90    #[serde(default = "default_alerts_restart_storm_threshold")]
91    pub restart_storm_threshold: u64,
92}
93
94impl Default for AlertsConfig {
95    fn default() -> Self {
96        Self {
97            enabled: false,
98            webhook_url: String::new(),
99            interval_secs: default_alerts_interval_secs(),
100            cooldown_secs: default_alerts_cooldown_secs(),
101            restart_storm_threshold: default_alerts_restart_storm_threshold(),
102        }
103    }
104}
105
106pub(crate) fn default_trust_report_interval_days() -> u64 {
107    7
108}
109
110/// Opt-in periodic "trust report": a digest of what the agent actually did
111/// — task runs, contract verdicts, token spend, guardrail interventions,
112/// delivery/recovery health — delivered to every control chat. OFF by
113/// default. Built entirely from data MicroClaw already records (usage
114/// ledger, contract events, tamper-evident audit chain), so enabling it
115/// costs no extra LLM calls.
116#[derive(Clone, Debug, Serialize, Deserialize)]
117pub struct TrustReportConfig {
118    #[serde(default)]
119    pub enabled: bool,
120    /// Days between reports. Default: 7.
121    #[serde(default = "default_trust_report_interval_days")]
122    pub interval_days: u64,
123}
124
125impl Default for TrustReportConfig {
126    fn default() -> Self {
127        Self {
128            enabled: false,
129            interval_days: default_trust_report_interval_days(),
130        }
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    #[allow(unused_imports)]
138    use crate::config::test_prelude::*;
139
140    #[test]
141    fn token_budget_disabled_never_blocks() {
142        let cfg = TokenBudgetConfig::default();
143        assert!(!cfg.blocks(false, i64::MAX));
144        assert!(!cfg.blocks(true, i64::MAX));
145    }
146
147    #[test]
148    fn token_budget_blocks_at_cap_but_exempts_control_chats() {
149        let cfg = TokenBudgetConfig {
150            daily_per_chat: 1000,
151            exempt_control_chats: true,
152        };
153        assert!(!cfg.blocks(false, 999));
154        assert!(cfg.blocks(false, 1000));
155        assert!(cfg.blocks(false, 5000));
156        // Control chat exempt by default
157        assert!(!cfg.blocks(true, 5000));
158        // ...unless exemption is turned off
159        let strict = TokenBudgetConfig {
160            daily_per_chat: 1000,
161            exempt_control_chats: false,
162        };
163        assert!(strict.blocks(true, 1000));
164    }
165}