Skip to main content

agentsight_protocol/
lib.rs

1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 eunomia-bpf org.
3//! Transport-independent AgentSight Node API contract.
4
5use serde::{Deserialize, Serialize};
6
7pub const PROTOCOL_VERSION: u32 = 1;
8pub const PRODUCT: &str = "agentsight";
9const SESSION_PREFIX: &str = "/api/v1/sessions/";
10
11#[derive(Debug, Serialize, Deserialize)]
12pub struct SessionMessageRequest {
13    pub message: String,
14}
15
16impl SessionMessageRequest {
17    pub fn validate(&self) -> Result<&str, &'static str> {
18        let message = self.message.trim();
19        (!message.is_empty() && message.len() <= 65_536)
20            .then_some(message)
21            .ok_or("message must contain 1-65536 bytes")
22    }
23}
24
25#[cfg_attr(target_arch = "wasm32", wasm_bindgen::prelude::wasm_bindgen)]
26pub fn info_path() -> String {
27    "/api/v1/info".into()
28}
29
30#[cfg_attr(target_arch = "wasm32", wasm_bindgen::prelude::wasm_bindgen)]
31pub fn snapshot_path(limit: usize) -> String {
32    format!("/api/v1/snapshot?audit_limit={limit}")
33}
34
35#[cfg_attr(target_arch = "wasm32", wasm_bindgen::prelude::wasm_bindgen)]
36pub fn overview_path() -> String {
37    "/api/v1/overview".into()
38}
39
40#[cfg_attr(target_arch = "wasm32", wasm_bindgen::prelude::wasm_bindgen)]
41pub fn session_path(id: &str) -> String {
42    format!("{SESSION_PREFIX}{id}")
43}
44
45#[cfg_attr(target_arch = "wasm32", wasm_bindgen::prelude::wasm_bindgen)]
46pub fn session_messages_path(id: &str) -> String {
47    format!("{SESSION_PREFIX}{id}/messages")
48}
49
50#[cfg_attr(target_arch = "wasm32", wasm_bindgen::prelude::wasm_bindgen)]
51pub fn session_message_body(message: &str) -> String {
52    serde_json::to_string(&SessionMessageRequest {
53        message: message.into(),
54    })
55    .expect("serializing a string request cannot fail")
56}
57
58pub fn session_detail_id(path: &str) -> Option<&str> {
59    let id = path.strip_prefix(SESSION_PREFIX)?;
60    (!id.is_empty() && !id.contains('/')).then_some(id)
61}
62
63pub fn session_message_id(path: &str) -> Option<&str> {
64    let id = path
65        .strip_prefix(SESSION_PREFIX)?
66        .strip_suffix("/messages")?;
67    (!id.is_empty() && !id.contains('/')).then_some(id)
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73
74    #[test]
75    fn public_paths_preserve_the_node_contract() {
76        assert_eq!(info_path(), "/api/v1/info");
77        assert_eq!(snapshot_path(500), "/api/v1/snapshot?audit_limit=500");
78        assert_eq!(overview_path(), "/api/v1/overview");
79        assert_eq!(session_path("s-1"), "/api/v1/sessions/s-1");
80        assert_eq!(
81            session_messages_path("s-1"),
82            "/api/v1/sessions/s-1/messages"
83        );
84        assert_eq!(session_detail_id("/api/v1/sessions/s-1"), Some("s-1"));
85        assert_eq!(
86            session_message_id("/api/v1/sessions/s-1/messages"),
87            Some("s-1")
88        );
89        assert_eq!(session_detail_id("/api/v1/sessions/s-1/messages"), None);
90    }
91
92    #[test]
93    fn message_validation_preserves_the_published_api() {
94        let request = SessionMessageRequest {
95            message: "  hello  ".into(),
96        };
97        assert_eq!(request.validate(), Ok("hello"));
98
99        for message in ["".to_string(), " \n\t ".to_string(), "x".repeat(65_537)] {
100            assert_eq!(
101                SessionMessageRequest { message }.validate(),
102                Err("message must contain 1-65536 bytes")
103            );
104        }
105        assert!(
106            SessionMessageRequest {
107                message: "x".repeat(65_536)
108            }
109            .validate()
110            .is_ok()
111        );
112    }
113}