Skip to main content

atd_protocol/
summary.rs

1use serde::{Deserialize, Serialize};
2
3use crate::enums::{ToolTier, ToolVisibility};
4use crate::tool::ToolDefinition;
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
8pub struct ToolSummary {
9    pub id: String,
10    #[serde(default)]
11    pub name: String,
12    pub description: String,
13    #[serde(default)]
14    pub domain: String,
15    #[serde(default)]
16    pub tags: Vec<String>,
17    #[serde(default)]
18    pub visibility: ToolVisibility,
19    #[serde(default = "default_tier")]
20    pub tier: ToolTier,
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub input_schema: Option<serde_json::Value>,
23}
24
25fn default_tier() -> ToolTier {
26    ToolTier::Warm
27}
28
29impl From<&ToolDefinition> for ToolSummary {
30    fn from(def: &ToolDefinition) -> Self {
31        Self {
32            id: def.id.clone(),
33            name: def.name.clone(),
34            description: def.description.clone(),
35            domain: def.capability.domain.clone(),
36            tags: def.capability.tags.clone(),
37            visibility: def.visibility,
38            // SP-12: propagate the definition's tier when set; fall back to
39            // Warm (the summary default) when absent.
40            tier: def.tier.unwrap_or_else(default_tier),
41            input_schema: Some(def.input_schema.clone()),
42        }
43    }
44}
45
46#[cfg(test)]
47mod tests {
48    use super::*;
49    use crate::enums::{BindingProtocol, SafetyLevel, TrustLevel};
50    use crate::tool::{ToolCapability, ToolResources, ToolSafety, ToolTrust};
51
52    fn def() -> ToolDefinition {
53        ToolDefinition {
54            id: "anos:fs.read".into(),
55            name: "Read File".into(),
56            description: "desc".into(),
57            version: "0.1.0".into(),
58            capability: ToolCapability {
59                domain: "fs".into(),
60                actions: vec!["read".into()],
61                tags: vec!["filesystem".into()],
62                intent_examples: vec![],
63            },
64            input_schema: serde_json::json!({}),
65            output_schema: serde_json::json!({}),
66            bindings: vec![crate::tool::ToolBinding {
67                protocol: BindingProtocol::Cli,
68                config: serde_json::json!({}),
69            }],
70            safety: ToolSafety {
71                level: SafetyLevel::Read,
72                dry_run: false,
73                side_effects: vec![],
74                data_sensitivity: None,
75            },
76            resources: ToolResources {
77                timeout_ms: 1000,
78                max_concurrent: 1,
79                rate_limit_per_min: None,
80                estimated_tokens: None,
81            },
82            trust: ToolTrust {
83                publisher: "anos".into(),
84                trust_level: TrustLevel::L2Tested,
85                signature: None,
86            },
87            visibility: ToolVisibility::Read,
88            required_capabilities: vec![],
89            tier: None,
90            errors: vec![],
91        }
92    }
93
94    #[test]
95    fn summary_is_derivable_from_definition() {
96        let s = ToolSummary::from(&def());
97        assert_eq!(s.id, "anos:fs.read");
98        assert_eq!(s.domain, "fs");
99        assert_eq!(s.tags, vec!["filesystem"]);
100        assert_eq!(s.tier, ToolTier::Warm);
101        // input_schema is populated from the definition
102        assert!(s.input_schema.is_some());
103    }
104
105    #[test]
106    fn input_schema_absent_in_wire_when_none() {
107        // A ToolSummary with input_schema: None must not emit the field in JSON
108        let s = ToolSummary {
109            id: "x".into(),
110            name: "x".into(),
111            description: "d".into(),
112            domain: "d".into(),
113            tags: vec![],
114            visibility: ToolVisibility::Read,
115            tier: ToolTier::Warm,
116            input_schema: None,
117        };
118        let j = serde_json::to_string(&s).unwrap();
119        assert!(
120            !j.contains("input_schema"),
121            "field should be absent when None: {j}"
122        );
123    }
124
125    #[test]
126    fn summary_roundtrip_json() {
127        let s = ToolSummary::from(&def());
128        let j = serde_json::to_string(&s).unwrap();
129        let back: ToolSummary = serde_json::from_str(&j).unwrap();
130        assert_eq!(back.id, s.id);
131    }
132
133    #[test]
134    fn missing_tier_defaults_to_warm() {
135        let j = r#"{"id":"a","name":"A","description":"d","domain":"x","tags":[]}"#;
136        let s: ToolSummary = serde_json::from_str(j).unwrap();
137        assert_eq!(s.tier, ToolTier::Warm);
138        assert_eq!(s.visibility, ToolVisibility::Read);
139    }
140
141    #[test]
142    fn summary_parses_anos_shape_with_missing_name_domain_tags() {
143        let j = r#"{"id":"anos:fs.read","description":"File Read","tier":"hot","visibility":"read","lifecycle":"Active"}"#;
144        let s: ToolSummary = serde_json::from_str(j).unwrap();
145        assert_eq!(s.id, "anos:fs.read");
146        assert_eq!(s.description, "File Read");
147        assert_eq!(s.name, ""); // defaults to empty; discover() fills in
148        assert_eq!(s.domain, ""); // defaults to empty; discover() fills in
149        assert!(s.tags.is_empty());
150        // lifecycle is not a ToolSummary field — serde ignores unknown keys by default
151    }
152}