Skip to main content

nexo_tool_meta/
event_source.rs

1//! [`EventSourceMeta`] — typed metadata describing the NATS
2//! subject + envelope that triggered an agent turn via the
3//! event-subscriber pipeline.
4//!
5//! Stamped on [`crate::BindingContext::event_source`] when the
6//! agent's inbound was synthesised from an event (vs arriving
7//! via a native channel like WhatsApp / Telegram). Microapps
8//! read it from `_meta.nexo.binding.event_source.subject` to
9//! distinguish event-triggered turns from human-message turns.
10
11use serde::{Deserialize, Serialize};
12use uuid::Uuid;
13
14/// Metadata describing the event that triggered an agent turn.
15///
16/// Wire-shape struct — caller-built by the event-subscriber
17/// runtime, caller-read by microapps and downstream tools.
18/// Field additions are deliberate semver-major (the JSON wire
19/// shape changes regardless), so this type is intentionally
20/// **not** `#[non_exhaustive]`.
21#[cfg_attr(feature = "ts-export", derive(ts_rs::TS), ts(export))]
22#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
23pub struct EventSourceMeta {
24    /// NATS subject that triggered the turn (e.g.
25    /// `"webhook.github_main.pull_request"`).
26    pub subject: String,
27    /// Envelope id from the upstream event payload (when
28    /// available — webhook events carry one;
29    /// custom-published events may not).
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub envelope_id: Option<Uuid>,
32    /// Synthesis mode the binding was configured with —
33    /// `"synthesize"` or `"tick"`. Tools can branch behaviour
34    /// (e.g. fetch payload only when mode is `"tick"`).
35    pub synthesis_mode: String,
36}
37
38/// Turn-log marker. Returns `"event:<source_id>"` so a
39/// downstream audit row can distinguish event-subscriber turns
40/// from native-channel inbounds. Mirrors the `"channel:<server>"`
41/// and `"webhook:<source_id>"` conventions.
42///
43/// # Example
44///
45/// ```
46/// use nexo_tool_meta::format_event_subscriber_source;
47/// assert_eq!(
48///     format_event_subscriber_source("github_main"),
49///     "event:github_main"
50/// );
51/// ```
52pub fn format_event_subscriber_source(source_id: &str) -> String {
53    format!("event:{source_id}")
54}
55
56/// Turn-log marker. Returns
57/// `"dispatch:<extension>:<channel>:<account_id>"` so the audit
58/// row distinguishes outbound publishes initiated by an extension
59/// from agent-initiated tool calls. Mirrors the `"event:..."` and
60/// `"webhook:..."` conventions.
61///
62/// # Example
63///
64/// ```
65/// use nexo_tool_meta::format_dispatch_source;
66/// assert_eq!(
67///     format_dispatch_source("marketing-saas", "whatsapp", "personal"),
68///     "dispatch:marketing-saas:whatsapp:personal"
69/// );
70/// ```
71pub fn format_dispatch_source(extension_id: &str, channel: &str, account_id: &str) -> String {
72    format!("dispatch:{extension_id}:{channel}:{account_id}")
73}
74
75/// Turn-log marker. Returns
76/// `"rate_limited:tool=<name>,binding=<id|none>,rps=<f64>"` so
77/// audit log queries can identify which `(binding, tool)` pairs
78/// hit caps most frequently. Operators wire this to billing /
79/// SaaS metric pipelines.
80///
81/// `binding_id` is `"none"` for the legacy single-tenant path
82/// (binding-less turns: delegation receive, heartbeat, and other
83/// older callers).
84///
85/// # Example
86///
87/// ```
88/// use nexo_tool_meta::format_rate_limit_hit;
89/// assert_eq!(
90///     format_rate_limit_hit("marketing_send_drip", Some("whatsapp:free"), 0.167),
91///     "rate_limited:tool=marketing_send_drip,binding=whatsapp:free,rps=0.167"
92/// );
93/// assert_eq!(
94///     format_rate_limit_hit("read_state", None, 1.0),
95///     "rate_limited:tool=read_state,binding=none,rps=1"
96/// );
97/// ```
98pub fn format_rate_limit_hit(tool: &str, binding_id: Option<&str>, rps: f64) -> String {
99    let bid = binding_id.unwrap_or("none");
100    format!("rate_limited:tool={tool},binding={bid},rps={rps}")
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn round_trips_through_serde() {
109        let original = EventSourceMeta {
110            subject: "webhook.github.pull_request".into(),
111            envelope_id: Some(Uuid::nil()),
112            synthesis_mode: "synthesize".into(),
113        };
114        let json = serde_json::to_string(&original).unwrap();
115        let back: EventSourceMeta = serde_json::from_str(&json).unwrap();
116        assert_eq!(original, back);
117    }
118
119    #[test]
120    fn skips_envelope_id_when_none() {
121        let meta = EventSourceMeta {
122            subject: "x".into(),
123            envelope_id: None,
124            synthesis_mode: "tick".into(),
125        };
126        let v = serde_json::to_value(&meta).unwrap();
127        assert!(v.get("subject").is_some());
128        assert!(v.get("envelope_id").is_none());
129        assert_eq!(v["synthesis_mode"], "tick");
130    }
131
132    #[test]
133    fn wire_shape_lock_down() {
134        let meta = EventSourceMeta {
135            subject: "s".into(),
136            envelope_id: Some(Uuid::from_u128(1)),
137            synthesis_mode: "synthesize".into(),
138        };
139        let v = serde_json::to_value(&meta).unwrap();
140        for key in ["subject", "envelope_id", "synthesis_mode"] {
141            assert!(v.get(key).is_some(), "missing key `{key}` in event_source");
142        }
143    }
144
145    #[test]
146    fn format_marker_prefixes_with_event() {
147        assert_eq!(
148            format_event_subscriber_source("github_main"),
149            "event:github_main"
150        );
151        assert_eq!(format_event_subscriber_source(""), "event:");
152    }
153
154    #[test]
155    fn format_dispatch_marker_concatenates_three_segments() {
156        assert_eq!(
157            format_dispatch_source("marketing-saas", "whatsapp", "personal"),
158            "dispatch:marketing-saas:whatsapp:personal"
159        );
160    }
161
162    #[test]
163    fn format_dispatch_marker_handles_empty_segments() {
164        // Pass-through; sanitisation is the boot supervisor's job.
165        assert_eq!(format_dispatch_source("", "", ""), "dispatch:::");
166    }
167
168    #[test]
169    fn format_rate_limit_hit_renders_canonical_string() {
170        assert_eq!(
171            format_rate_limit_hit("marketing_send_drip", Some("whatsapp:free"), 0.167),
172            "rate_limited:tool=marketing_send_drip,binding=whatsapp:free,rps=0.167"
173        );
174    }
175
176    #[test]
177    fn format_rate_limit_hit_with_binding_none_says_none() {
178        assert_eq!(
179            format_rate_limit_hit("read_state", None, 1.0),
180            "rate_limited:tool=read_state,binding=none,rps=1"
181        );
182    }
183}