Skip to main content

tandem_server/
preset_summary.rs

1use std::collections::BTreeSet;
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Serialize, Deserialize, Default)]
6pub struct CapabilitySetInput {
7    #[serde(default)]
8    pub required: Vec<String>,
9    #[serde(default)]
10    pub optional: Vec<String>,
11}
12
13#[derive(Debug, Clone, Serialize, Deserialize, Default)]
14pub struct CapabilitySummaryInput {
15    #[serde(default)]
16    pub agent: CapabilitySetInput,
17    #[serde(default)]
18    pub tasks: Vec<CapabilitySetInput>,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct CapabilitySummaryOutput {
23    pub agent: CapabilitySetInput,
24    pub automation: CapabilitySetInput,
25    pub totals: CapabilityTotals,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct CapabilityTotals {
30    pub required_count: usize,
31    pub optional_count: usize,
32    pub task_count: usize,
33}
34
35pub fn summarize(input: CapabilitySummaryInput) -> CapabilitySummaryOutput {
36    let agent = normalize(input.agent);
37    let task_count = input.tasks.len();
38    let mut automation_required = BTreeSet::<String>::new();
39    let mut automation_optional = BTreeSet::<String>::new();
40    for task in input.tasks {
41        let normalized = normalize(task);
42        for cap in normalized.required {
43            automation_required.insert(cap);
44        }
45        for cap in normalized.optional {
46            if !automation_required.contains(&cap) {
47                automation_optional.insert(cap);
48            }
49        }
50    }
51    // Agent capabilities are also required for automation when tasks bind that agent.
52    for cap in &agent.required {
53        automation_required.insert(cap.clone());
54        automation_optional.remove(cap);
55    }
56    for cap in &agent.optional {
57        if !automation_required.contains(cap) {
58            automation_optional.insert(cap.clone());
59        }
60    }
61    let automation = CapabilitySetInput {
62        required: automation_required.iter().cloned().collect(),
63        optional: automation_optional.iter().cloned().collect(),
64    };
65    let totals = CapabilityTotals {
66        required_count: automation.required.len(),
67        optional_count: automation.optional.len(),
68        task_count,
69    };
70    CapabilitySummaryOutput {
71        agent,
72        automation,
73        totals,
74    }
75}
76
77fn normalize(input: CapabilitySetInput) -> CapabilitySetInput {
78    let mut required = BTreeSet::<String>::new();
79    let mut optional = BTreeSet::<String>::new();
80    for cap in input.required {
81        let id = cap.trim();
82        if !id.is_empty() {
83            required.insert(id.to_string());
84        }
85    }
86    for cap in input.optional {
87        let id = cap.trim();
88        if id.is_empty() {
89            continue;
90        }
91        if !required.contains(id) {
92            optional.insert(id.to_string());
93        }
94    }
95    CapabilitySetInput {
96        required: required.into_iter().collect(),
97        optional: optional.into_iter().collect(),
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn required_dominates_optional_across_agent_and_tasks() {
107        let out = summarize(CapabilitySummaryInput {
108            agent: CapabilitySetInput {
109                required: vec!["github.create_pull_request".to_string()],
110                optional: vec!["slack.post_message".to_string()],
111            },
112            tasks: vec![
113                CapabilitySetInput {
114                    required: vec!["slack.post_message".to_string()],
115                    optional: vec!["github.create_pull_request".to_string()],
116                },
117                CapabilitySetInput {
118                    required: vec![],
119                    optional: vec!["jira.create_issue".to_string()],
120                },
121            ],
122        });
123        assert_eq!(
124            out.automation.required,
125            vec![
126                "github.create_pull_request".to_string(),
127                "slack.post_message".to_string()
128            ]
129        );
130        assert_eq!(
131            out.automation.optional,
132            vec!["jira.create_issue".to_string()]
133        );
134    }
135}