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}