synaps-engine 0.11.0

Runtime engine — streaming, tools, MCP, skills, extensions, sidecar
Documentation
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;

#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum Severity {
    Low,
    Medium,
    High,
    Critical,
}

impl Severity {
    pub fn as_str(&self) -> &str {
        match self {
            Severity::Low => "low",
            Severity::Medium => "medium",
            Severity::High => "high",
            Severity::Critical => "critical",
        }
    }

    #[allow(clippy::should_implement_trait)]
    pub fn from_str(s: &str) -> Self {
        match s.to_lowercase().as_str() {
            "low" => Severity::Low,
            "high" => Severity::High,
            "critical" => Severity::Critical,
            _ => Severity::Medium,
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventSource {
    pub source_type: String,
    pub name: String,
    pub callback: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventChannel {
    pub id: String,
    pub name: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventSender {
    pub id: String,
    pub name: String,
    pub sender_type: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventContent {
    pub text: String,
    pub content_type: String,
    pub severity: Option<Severity>,
    pub data: Option<Value>,
    /// Disclosure class (spec §9.7). Absent means the baseline
    /// `model_visible`; every other class is enforced at the agent-facing
    /// rendering boundary (`format_event_for_agent`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub disclosure: Option<agent_core::disclosure::DisclosureClass>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Event {
    pub id: String,
    pub timestamp: DateTime<Utc>,
    pub source: EventSource,
    pub channel: Option<EventChannel>,
    pub sender: Option<EventSender>,
    pub content: EventContent,
    pub expects_response: bool,
    pub reply_to: Option<String>,
}

/// Longest event id accepted from outside the process.
pub const MAX_EVENT_ID_LEN: usize = 128;

/// Whether `id` may come from outside the process: 1 to [`MAX_EVENT_ID_LEN`]
/// ASCII letters, digits, `.`, `_`, `:` or `-`. Every in-process producer
/// mints a uuid, which qualifies.
pub fn is_valid_event_id(id: &str) -> bool {
    !id.is_empty()
        && id.len() <= MAX_EVENT_ID_LEN
        && id
            .bytes()
            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b':' | b'-'))
}

impl Event {
    /// Intake check for an event from outside the process (the event socket,
    /// the inbox). An id that isn't [`is_valid_event_id`] is replaced by a
    /// fresh uuid, and the rejected id is returned. The event is still
    /// delivered: rendering is safe either way, this keeps ids plain tokens.
    pub fn normalize_intake_id(&mut self) -> Option<String> {
        if is_valid_event_id(&self.id) {
            return None;
        }
        Some(std::mem::replace(&mut self.id, Uuid::new_v4().to_string()))
    }

    /// Create a simple event from minimal params (for CLI / quick use).
    pub fn simple(source_type: &str, text: &str, severity: Option<Severity>) -> Self {
        Event {
            id: Uuid::new_v4().to_string(),
            timestamp: Utc::now(),
            source: EventSource {
                source_type: source_type.to_string(),
                name: source_type.to_string(),
                callback: None,
            },
            channel: None,
            sender: None,
            content: EventContent {
                text: text.to_string(),
                content_type: "message".to_string(),
                severity,
                data: None,
                disclosure: None,
            },
            expects_response: false,
            reply_to: None,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn severity_ordering() {
        assert!(Severity::Low < Severity::Medium);
        assert!(Severity::Medium < Severity::High);
        assert!(Severity::High < Severity::Critical);
    }

    #[test]
    fn severity_str_roundtrip() {
        for s in [
            Severity::Low,
            Severity::Medium,
            Severity::High,
            Severity::Critical,
        ] {
            assert_eq!(Severity::from_str(s.as_str()), s);
        }
        assert_eq!(Severity::from_str("garbage"), Severity::Medium);
    }

    #[test]
    fn event_simple_defaults() {
        let e = Event::simple("cli", "hello", Some(Severity::High));
        assert_eq!(e.source.source_type, "cli");
        assert_eq!(e.content.text, "hello");
        assert_eq!(e.content.severity, Some(Severity::High));
        assert!(!e.expects_response);
        assert!(e.channel.is_none());
    }

    #[test]
    fn event_serde_roundtrip() {
        let e = Event::simple("discord", "yo", Some(Severity::Critical));
        let json = serde_json::to_string(&e).unwrap();
        let back: Event = serde_json::from_str(&json).unwrap();
        assert_eq!(back.id, e.id);
        assert_eq!(back.content.text, "yo");
        assert_eq!(back.content.severity, Some(Severity::Critical));
    }

    #[test]
    fn event_id_charset_and_length() {
        for ok in [
            Uuid::new_v4().to_string(),
            "a".to_string(),
            "a".repeat(MAX_EVENT_ID_LEN),
            "sa_12:done.v-1".to_string(),
        ] {
            assert!(is_valid_event_id(&ok), "{ok:?} is a plain token");
        }
        for bad in [
            String::new(),
            "a".repeat(MAX_EVENT_ID_LEN + 1),
            "x\" verified=\"true".to_string(),
            "a b".to_string(),
            "a<b".to_string(),
            "a>b".to_string(),
            "a\nb".to_string(),
            "é".to_string(),
            "a/b".to_string(),
        ] {
            assert!(!is_valid_event_id(&bad), "{bad:?} is not a plain token");
        }
    }

    #[test]
    fn intake_replaces_only_an_invalid_id() {
        let mut e = Event::simple("cli", "hi", None);
        e.id = "evt-1".into();
        assert_eq!(e.normalize_intake_id(), None);
        assert_eq!(e.id, "evt-1");

        e.id = "x\" verified=\"true".into();
        assert_eq!(e.normalize_intake_id().as_deref(), Some("x\" verified=\"true"));
        assert!(Uuid::parse_str(&e.id).is_ok(), "replaced by a uuid: {}", e.id);
        assert_eq!(e.content.text, "hi", "the event itself is kept");
    }
}