Skip to main content

codeswarm_adapters/
collaboration.rs

1//! Bounded public context shared between sequential relay participants.
2
3#[derive(Clone, Debug, Eq, PartialEq)]
4pub struct PublicEvent {
5    pub speaker: String,
6    pub text: String,
7}
8
9#[derive(Clone, Debug, Eq, PartialEq)]
10pub struct CollaborationContext {
11    shared_task: Option<String>,
12    events: Vec<PublicEvent>,
13    seen: Vec<usize>,
14    truncated: Vec<bool>,
15}
16
17impl CollaborationContext {
18    pub fn new(agent_count: usize) -> Self {
19        Self {
20            shared_task: None,
21            events: Vec::new(),
22            seen: vec![0; agent_count],
23            truncated: vec![false; agent_count],
24        }
25    }
26
27    pub fn set_shared_task(&mut self, task: impl Into<String>) {
28        self.shared_task = Some(task.into());
29    }
30
31    pub fn shared_task(&self) -> Option<&str> {
32        self.shared_task.as_deref()
33    }
34
35    pub fn add_agent(&mut self) {
36        self.seen.push(0);
37        self.truncated.push(false);
38    }
39
40    pub fn record(&mut self, speaker: impl Into<String>, text: impl Into<String>, active: &[bool]) {
41        self.prune(active);
42        self.events.push(PublicEvent {
43            speaker: speaker.into(),
44            text: compact(text.into()),
45        });
46    }
47
48    pub fn mark_seen(&mut self, slot: usize) {
49        if let Some(seen) = self.seen.get_mut(slot) {
50            *seen = self.events.len();
51        }
52    }
53
54    /// Rewind one agent's watermark after a replacement/reload so its next
55    /// turn receives the retained public journal again.
56    pub fn rewind(&mut self, slot: usize) {
57        if let Some(seen) = self.seen.get_mut(slot) {
58            *seen = 0;
59        }
60    }
61
62    /// Follow two roster members when their logical slots exchange places.
63    /// Watermarks belong to the adapter, rather than to the numeric slot, so
64    /// moving a live agent must move its context cursor with it as well.
65    pub fn swap_agents(&mut self, first: usize, second: usize) {
66        if first < self.seen.len() && second < self.seen.len() {
67            self.seen.swap(first, second);
68            self.truncated.swap(first, second);
69        }
70    }
71
72    pub fn unseen(&mut self, slot: usize) -> String {
73        let start = self.seen.get(slot).copied().unwrap_or(self.events.len());
74        let mut updates = self.events[start..]
75            .iter()
76            .filter(|event| !event.text.is_empty())
77            .map(|event| format!("{}:\n{}", event.speaker, event.text))
78            .collect::<Vec<_>>();
79        if self.truncated.get(slot).copied().unwrap_or(false) {
80            updates.insert(
81                0,
82                "[CodeSwarm omitted older unseen updates to protect context.]".into(),
83            );
84            self.truncated[slot] = false;
85        }
86        limit(updates, 24_000)
87    }
88
89    fn prune(&mut self, active: &[bool]) {
90        let consumed = active
91            .iter()
92            .enumerate()
93            .filter_map(|(slot, enabled)| {
94                enabled.then_some(self.seen.get(slot).copied().unwrap_or(0))
95            })
96            .min()
97            .unwrap_or(0);
98        if consumed > 0 {
99            self.events.drain(..consumed);
100            for seen in &mut self.seen {
101                *seen = seen.saturating_sub(consumed);
102            }
103        }
104        while self.events.len() >= 200
105            || self
106                .events
107                .iter()
108                .map(|event| event.text.len())
109                .sum::<usize>()
110                >= 48_000
111        {
112            self.events.remove(0);
113            for (slot, seen) in self.seen.iter_mut().enumerate() {
114                if *seen > 0 {
115                    *seen -= 1;
116                } else if active.get(slot).copied().unwrap_or(false) {
117                    self.truncated[slot] = true;
118                }
119            }
120        }
121    }
122}
123
124fn compact(text: String) -> String {
125    const LIMIT: usize = 12_000;
126    if text.len() <= LIMIT {
127        return text;
128    }
129    // `String::len` is a byte count, but responses can contain arbitrary
130    // Unicode. Find split points on character boundaries before slicing;
131    // otherwise a long non-ASCII response can panic the relay while it is
132    // compacting public context.
133    let head = floor_char_boundary(&text, LIMIT / 2);
134    let tail_budget = LIMIT - head;
135    let tail_start = ceil_char_boundary(&text, text.len().saturating_sub(tail_budget));
136    format!(
137        "{}\n\n[CodeSwarm omitted the middle of this response to protect context.]\n\n{}",
138        &text[..head],
139        &text[tail_start..],
140    )
141}
142
143fn floor_char_boundary(text: &str, index: usize) -> usize {
144    let mut index = index.min(text.len());
145    while index > 0 && !text.is_char_boundary(index) {
146        index -= 1;
147    }
148    index
149}
150
151fn ceil_char_boundary(text: &str, index: usize) -> usize {
152    let mut index = index.min(text.len());
153    while index < text.len() && !text.is_char_boundary(index) {
154        index += 1;
155    }
156    index
157}
158
159fn limit(mut updates: Vec<String>, limit: usize) -> String {
160    let rendered = updates.join("\n\n");
161    if rendered.len() <= limit {
162        return rendered;
163    }
164    let marker = "[CodeSwarm omitted older unseen updates to protect context.]";
165    let mut selected = Vec::new();
166    let mut used = 0;
167    while let Some(update) = updates.pop() {
168        let added = update.len() + 2;
169        if used + added > limit - marker.len() - 2 {
170            break;
171        }
172        used += added;
173        selected.push(update);
174    }
175    selected.reverse();
176    format!("{marker}\n\n{}", selected.join("\n\n"))
177}
178
179#[cfg(test)]
180mod tests {
181    use super::CollaborationContext;
182
183    #[test]
184    fn only_unseen_public_text_is_sent_to_each_agent() {
185        let mut context = CollaborationContext::new(2);
186        context.record("Human", "task", &[true, true]);
187        context.mark_seen(0);
188        assert_eq!(context.unseen(0), "");
189        assert_eq!(context.unseen(1), "Human:\ntask");
190    }
191
192    #[test]
193    fn rewind_replays_retained_context_to_a_reloaded_agent() {
194        let mut context = CollaborationContext::new(1);
195        context.record("Agent", "answer", &[true]);
196        context.mark_seen(0);
197        assert_eq!(context.unseen(0), "");
198        context.rewind(0);
199        assert_eq!(context.unseen(0), "Agent:\nanswer");
200    }
201
202    #[test]
203    fn long_history_is_bounded_without_losing_recent_updates() {
204        let mut context = CollaborationContext::new(1);
205        for index in 0..250 {
206            context.record("Agent", format!("reply {index}"), &[true]);
207        }
208        let unseen = context.unseen(0);
209        assert!(unseen.contains("reply 249"));
210        assert!(unseen.len() <= 24_000);
211    }
212
213    #[test]
214    fn long_unicode_response_is_compacted_without_slicing_panic() {
215        let mut context = CollaborationContext::new(1);
216        context.record("Agent", "🚀漢字".repeat(5_000), &[true]);
217        let unseen = context.unseen(0);
218        assert!(unseen.contains("omitted the middle"));
219        assert!(unseen.contains("🚀"));
220        assert!(unseen.is_char_boundary(0));
221    }
222}