Skip to main content

tf_types/
bridge_a2a.rs

1//! A2A (agent-to-agent) protocol bridge — mirror of TS bridge-a2a.ts.
2
3use crate::bridges::{Bridge, BridgeError, BridgeKind};
4
5#[derive(Clone, Debug)]
6pub struct A2AAgentCard {
7    pub agent_id: String,
8    pub display_name: Option<String>,
9    pub public_key_b64: Option<String>,
10    pub public_key_algorithm: Option<String>,
11    pub capabilities: Vec<A2ACapability>,
12    pub trust_domain: String,
13}
14
15#[derive(Clone, Debug)]
16pub struct A2ACapability {
17    pub name: String,
18    pub description: Option<String>,
19    pub risk: Option<String>,
20}
21
22#[derive(Clone, Debug)]
23pub struct A2ABridgeConfig {
24    pub bridge_id: String,
25    pub trust_domain: String,
26    pub default_risk: Option<String>,
27}
28
29pub struct A2ABridge {
30    cfg: A2ABridgeConfig,
31}
32
33impl Bridge for A2ABridge {
34    fn bridge_id(&self) -> &str {
35        &self.cfg.bridge_id
36    }
37    fn kind(&self) -> BridgeKind {
38        BridgeKind::A2a
39    }
40    fn trust_domain(&self) -> &str {
41        &self.cfg.trust_domain
42    }
43}
44
45#[derive(Clone, Debug)]
46pub struct ProjectedActor {
47    pub actor_id: String,
48    pub algorithm: String,
49    pub public_key: String,
50    pub capabilities: Vec<(String, String)>, // (action_name, risk)
51}
52
53impl A2ABridge {
54    pub fn new(cfg: A2ABridgeConfig) -> Self {
55        Self { cfg }
56    }
57
58    pub fn accept_agent_card(&self, card: &A2AAgentCard) -> Result<ProjectedActor, BridgeError> {
59        if card.agent_id.is_empty() || card.trust_domain.is_empty() {
60            return Err(BridgeError::InvalidInput(
61                "AgentCard missing agent_id or trust_domain".into(),
62            ));
63        }
64        let actor_id = format!("tf:actor:agent:{}/{}", card.trust_domain, card.agent_id);
65        let (algorithm, public_key) = match (&card.public_key_b64, &card.public_key_algorithm) {
66            (Some(pk), Some(alg)) => (alg.clone(), pk.clone()),
67            (Some(pk), None) => ("ed25519".to_string(), pk.clone()),
68            _ => (
69                "external-attestation".to_string(),
70                format!("agent-card:{}", card.agent_id),
71            ),
72        };
73        let mut caps = Vec::with_capacity(card.capabilities.len());
74        for c in &card.capabilities {
75            let action = a2a_normalise_capability(&c.name, None);
76            if !is_valid_action_name(&action) {
77                return Err(BridgeError::Rejected(format!(
78                    "A2A capability {} does not normalise to a valid action name (got {})",
79                    c.name, action
80                )));
81            }
82            let risk = c
83                .risk
84                .clone()
85                .or_else(|| self.cfg.default_risk.clone())
86                .unwrap_or_else(|| "R2".to_string());
87            caps.push((action, risk));
88        }
89        Ok(ProjectedActor {
90            actor_id,
91            algorithm,
92            public_key,
93            capabilities: caps,
94        })
95    }
96}
97
98/// Mirror of TS `a2aNormaliseCapability`: lowercase, non-alphanumeric
99/// runs collapse to `_`, leading/trailing `_` stripped, prepend `a2a.`
100/// if no dot is present.
101pub fn a2a_normalise_capability(name: &str, prefix: Option<&str>) -> String {
102    let mut buf = String::with_capacity(name.len());
103    let mut last_underscore = false;
104    for c in name.chars() {
105        if c.is_ascii_alphanumeric() {
106            buf.push(c.to_ascii_lowercase());
107            last_underscore = false;
108        } else if !last_underscore {
109            buf.push('_');
110            last_underscore = true;
111        }
112    }
113    let scrubbed = buf.trim_matches('_').to_string();
114    let with_prefix = match prefix {
115        Some(p) => format!("{}.{}", p, scrubbed),
116        None => scrubbed,
117    };
118    if with_prefix.contains('.') {
119        with_prefix
120    } else {
121        format!("a2a.{}", with_prefix)
122    }
123}
124
125fn is_valid_action_name(s: &str) -> bool {
126    let mut segs = s.split('.');
127    let first = match segs.next() {
128        Some(x) => x,
129        None => return false,
130    };
131    if !is_valid_action_segment(first) {
132        return false;
133    }
134    let mut count = 1;
135    for seg in segs {
136        if !is_valid_action_segment(seg) {
137            return false;
138        }
139        count += 1;
140    }
141    count >= 2
142}
143
144fn is_valid_action_segment(s: &str) -> bool {
145    let mut chars = s.chars();
146    let first = match chars.next() {
147        Some(c) => c,
148        None => return false,
149    };
150    if !first.is_ascii_lowercase() {
151        return false;
152    }
153    chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn normalise_basic() {
162        // dot is non-alphanumeric → collapsed to `_`, then `a2a.` prefix added.
163        assert_eq!(
164            a2a_normalise_capability("filesystem.read", None),
165            "a2a.filesystem_read"
166        );
167        assert_eq!(a2a_normalise_capability("ping", None), "a2a.ping");
168        assert_eq!(
169            a2a_normalise_capability("Read File!", None),
170            "a2a.read_file"
171        );
172        assert_eq!(
173            a2a_normalise_capability("system-info", Some("tools")),
174            "tools.system_info"
175        );
176    }
177
178    #[test]
179    fn accept_agent_card_round_trip() {
180        let bridge = A2ABridge::new(A2ABridgeConfig {
181            bridge_id: "tf-a2a".into(),
182            trust_domain: "example.com".into(),
183            default_risk: Some("R2".into()),
184        });
185        let card = A2AAgentCard {
186            agent_id: "code-helper".into(),
187            display_name: None,
188            public_key_b64: Some("AAAA".into()),
189            public_key_algorithm: Some("ed25519".into()),
190            capabilities: vec![A2ACapability {
191                name: "fs.read".into(),
192                description: None,
193                risk: None,
194            }],
195            trust_domain: "example.com".into(),
196        };
197        let p = bridge.accept_agent_card(&card).expect("project");
198        assert_eq!(p.actor_id, "tf:actor:agent:example.com/code-helper");
199        assert_eq!(p.capabilities[0].0, "a2a.fs_read");
200        assert_eq!(p.capabilities[0].1, "R2");
201    }
202
203    #[test]
204    fn missing_agent_id_rejected() {
205        let bridge = A2ABridge::new(A2ABridgeConfig {
206            bridge_id: "tf-a2a".into(),
207            trust_domain: "example.com".into(),
208            default_risk: None,
209        });
210        let card = A2AAgentCard {
211            agent_id: "".into(),
212            display_name: None,
213            public_key_b64: None,
214            public_key_algorithm: None,
215            capabilities: vec![],
216            trust_domain: "example.com".into(),
217        };
218        assert!(bridge.accept_agent_card(&card).is_err());
219    }
220}