Skip to main content

autonomic_core/
events.rs

1//! Autonomic event constructors.
2//!
3//! Economic events use `EventKind::Custom` with `"autonomic."` prefix.
4//! This is forward-compatible — Custom events round-trip through Lago.
5//! Events will be promoted to canonical `EventKind` variants once stabilized.
6
7use aios_protocol::event::EventKind;
8use serde::{Deserialize, Serialize};
9use serde_json::json;
10
11use crate::economic::{CostReason, EconomicMode};
12
13/// Prefix for all Autonomic custom events.
14pub const AUTONOMIC_EVENT_PREFIX: &str = "autonomic.";
15
16/// Autonomic-specific event types that wrap as `EventKind::Custom`.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18#[serde(tag = "type", rename_all = "PascalCase")]
19pub enum AutonomicEvent {
20    /// A cost was charged to the agent.
21    CostCharged {
22        amount_micro_credits: i64,
23        reason: CostReason,
24        balance_after: i64,
25    },
26    /// The economic mode changed.
27    EconomicModeChanged {
28        from: EconomicMode,
29        to: EconomicMode,
30        reason: String,
31    },
32    /// A gating decision was made by the controller.
33    GatingDecision {
34        session_id: String,
35        rationale: Vec<String>,
36        economic_mode: EconomicMode,
37    },
38    /// Credits were deposited (revenue, grant, transfer).
39    CreditDeposited {
40        amount_micro_credits: i64,
41        source: String,
42        balance_after: i64,
43    },
44}
45
46impl AutonomicEvent {
47    /// Convert this event into a canonical `EventKind::Custom`.
48    pub fn into_event_kind(self) -> EventKind {
49        let (event_type, data) = match &self {
50            Self::CostCharged {
51                amount_micro_credits,
52                reason,
53                balance_after,
54            } => (
55                "autonomic.CostCharged",
56                json!({
57                    "amount_micro_credits": amount_micro_credits,
58                    "reason": reason,
59                    "balance_after": balance_after,
60                }),
61            ),
62            Self::EconomicModeChanged { from, to, reason } => (
63                "autonomic.EconomicModeChanged",
64                json!({
65                    "from": from,
66                    "to": to,
67                    "reason": reason,
68                }),
69            ),
70            Self::GatingDecision {
71                session_id,
72                rationale,
73                economic_mode,
74            } => (
75                "autonomic.GatingDecision",
76                json!({
77                    "session_id": session_id,
78                    "rationale": rationale,
79                    "economic_mode": economic_mode,
80                }),
81            ),
82            Self::CreditDeposited {
83                amount_micro_credits,
84                source,
85                balance_after,
86            } => (
87                "autonomic.CreditDeposited",
88                json!({
89                    "amount_micro_credits": amount_micro_credits,
90                    "source": source,
91                    "balance_after": balance_after,
92                }),
93            ),
94        };
95        EventKind::Custom {
96            event_type: event_type.to_owned(),
97            data,
98        }
99    }
100
101    /// Check if a `Custom` event is an Autonomic event by its prefix.
102    pub fn is_autonomic_event(event_type: &str) -> bool {
103        event_type.starts_with(AUTONOMIC_EVENT_PREFIX)
104    }
105
106    /// Try to parse an `EventKind::Custom` back into an `AutonomicEvent`.
107    pub fn from_custom(event_type: &str, data: &serde_json::Value) -> Option<Self> {
108        if !Self::is_autonomic_event(event_type) {
109            return None;
110        }
111
112        match event_type {
113            "autonomic.CostCharged" => {
114                let amount = data.get("amount_micro_credits")?.as_i64()?;
115                let reason: CostReason =
116                    serde_json::from_value(data.get("reason")?.clone()).ok()?;
117                let balance = data.get("balance_after")?.as_i64()?;
118                Some(Self::CostCharged {
119                    amount_micro_credits: amount,
120                    reason,
121                    balance_after: balance,
122                })
123            }
124            "autonomic.EconomicModeChanged" => {
125                let from: EconomicMode = serde_json::from_value(data.get("from")?.clone()).ok()?;
126                let to: EconomicMode = serde_json::from_value(data.get("to")?.clone()).ok()?;
127                let reason = data.get("reason")?.as_str()?.to_owned();
128                Some(Self::EconomicModeChanged { from, to, reason })
129            }
130            "autonomic.GatingDecision" => {
131                let session_id = data.get("session_id")?.as_str()?.to_owned();
132                let rationale: Vec<String> =
133                    serde_json::from_value(data.get("rationale")?.clone()).ok()?;
134                let economic_mode: EconomicMode =
135                    serde_json::from_value(data.get("economic_mode")?.clone()).ok()?;
136                Some(Self::GatingDecision {
137                    session_id,
138                    rationale,
139                    economic_mode,
140                })
141            }
142            "autonomic.CreditDeposited" => {
143                let amount = data.get("amount_micro_credits")?.as_i64()?;
144                let source = data.get("source")?.as_str()?.to_owned();
145                let balance = data.get("balance_after")?.as_i64()?;
146                Some(Self::CreditDeposited {
147                    amount_micro_credits: amount,
148                    source,
149                    balance_after: balance,
150                })
151            }
152            _ => None,
153        }
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn cost_charged_to_event_kind() {
163        let event = AutonomicEvent::CostCharged {
164            amount_micro_credits: 150,
165            reason: CostReason::ModelInference {
166                model: "claude-sonnet".into(),
167                prompt_tokens: 100,
168                completion_tokens: 50,
169            },
170            balance_after: 9_999_850,
171        };
172        let kind = event.into_event_kind();
173        if let EventKind::Custom { event_type, data } = &kind {
174            assert_eq!(event_type, "autonomic.CostCharged");
175            assert_eq!(data["amount_micro_credits"], 150);
176            assert_eq!(data["balance_after"], 9_999_850);
177        } else {
178            panic!("expected Custom variant");
179        }
180    }
181
182    #[test]
183    fn event_kind_roundtrip_through_custom() {
184        let event = AutonomicEvent::EconomicModeChanged {
185            from: EconomicMode::Sovereign,
186            to: EconomicMode::Conserving,
187            reason: "balance dropping".into(),
188        };
189        let kind = event.into_event_kind();
190
191        // Serialize and deserialize as EventKind
192        let json = serde_json::to_string(&kind).unwrap();
193        let back: EventKind = serde_json::from_str(&json).unwrap();
194
195        if let EventKind::Custom { event_type, data } = back {
196            assert_eq!(event_type, "autonomic.EconomicModeChanged");
197            let parsed = AutonomicEvent::from_custom(&event_type, &data).unwrap();
198            assert!(matches!(
199                parsed,
200                AutonomicEvent::EconomicModeChanged {
201                    to: EconomicMode::Conserving,
202                    ..
203                }
204            ));
205        } else {
206            panic!("expected Custom variant after roundtrip");
207        }
208    }
209
210    #[test]
211    fn is_autonomic_event_prefix() {
212        assert!(AutonomicEvent::is_autonomic_event("autonomic.CostCharged"));
213        assert!(AutonomicEvent::is_autonomic_event("autonomic.Anything"));
214        assert!(!AutonomicEvent::is_autonomic_event("other.Event"));
215        assert!(!AutonomicEvent::is_autonomic_event("CostCharged"));
216    }
217
218    #[test]
219    fn from_custom_returns_none_for_non_autonomic() {
220        let result = AutonomicEvent::from_custom("other.Event", &json!({}));
221        assert!(result.is_none());
222    }
223
224    #[test]
225    fn credit_deposited_roundtrip() {
226        let event = AutonomicEvent::CreditDeposited {
227            amount_micro_credits: 5_000_000,
228            source: "grant".into(),
229            balance_after: 15_000_000,
230        };
231        let kind = event.into_event_kind();
232        if let EventKind::Custom { event_type, data } = kind {
233            let parsed = AutonomicEvent::from_custom(&event_type, &data).unwrap();
234            assert!(matches!(
235                parsed,
236                AutonomicEvent::CreditDeposited {
237                    amount_micro_credits: 5_000_000,
238                    ..
239                }
240            ));
241        } else {
242            panic!("expected Custom");
243        }
244    }
245}