Skip to main content

deepstrike_core/context/
units.rs

1use crate::types::message::{Content, ContentPart, Message, Role};
2use std::collections::BTreeSet;
3use std::ops::Range;
4
5/// Partition history into indivisible conversational transactions.
6///
7/// A new user turn starts a unit only when no tool call is awaiting a result. This keeps an
8/// assistant call, every correlated result, and the trailing answer on the same side of every
9/// compression/render boundary. Malformed recovery input is kept together until its open calls
10/// close, which is safer than manufacturing an orphaned result.
11pub fn unit_boundaries(messages: &[Message]) -> Vec<Range<usize>> {
12    if messages.is_empty() {
13        return Vec::new();
14    }
15
16    let mut boundaries = Vec::new();
17    let mut start = 0usize;
18    let mut open_calls = BTreeSet::new();
19    let mut has_assistant = false;
20    let mut has_tool_transaction = false;
21    let mut trailing_answer_consumed = false;
22
23    for (index, message) in messages.iter().enumerate() {
24        let is_trailing_answer = has_tool_transaction
25            && !trailing_answer_consumed
26            && open_calls.is_empty()
27            && message.role == Role::Assistant
28            && message.tool_calls.is_empty();
29        let starts_new_user_unit = message.role == Role::User && open_calls.is_empty();
30        let starts_new_assistant_unit = message.role == Role::Assistant
31            && open_calls.is_empty()
32            && !is_trailing_answer
33            && has_assistant;
34        let starts_new_orphan_tool_unit =
35            message.role == Role::Tool && open_calls.is_empty() && index > start;
36        if index > start
37            && (starts_new_user_unit || starts_new_assistant_unit || starts_new_orphan_tool_unit)
38        {
39            boundaries.push(start..index);
40            start = index;
41            has_assistant = false;
42            has_tool_transaction = false;
43            trailing_answer_consumed = false;
44        } else if is_trailing_answer {
45            trailing_answer_consumed = true;
46        }
47
48        if message.role == Role::Assistant {
49            has_assistant = true;
50        }
51        for call in &message.tool_calls {
52            open_calls.insert(call.id.to_string());
53            has_tool_transaction = true;
54        }
55        if let Content::Parts(parts) = &message.content {
56            for part in parts {
57                if let ContentPart::ToolResult { call_id, .. } = part {
58                    open_calls.remove(call_id.as_str());
59                }
60            }
61        }
62    }
63
64    boundaries.push(start..messages.len());
65    boundaries
66}
67
68/// Rust-side equivalent of the strict provider replay pairing invariant. This is intentionally
69/// small and deterministic so compression and rendering tests can prove they never manufacture an
70/// orphan or leave a call unanswered when their input was valid.
71pub(crate) fn strict_tool_pairing_is_valid(messages: &[Message]) -> bool {
72    let mut pending: Option<BTreeSet<String>> = None;
73    let mut completed = BTreeSet::new();
74
75    let pending_complete = |pending: &Option<BTreeSet<String>>, completed: &BTreeSet<String>| {
76        pending
77            .as_ref()
78            .is_none_or(|ids| ids.iter().all(|id| completed.contains(id)))
79    };
80
81    for message in messages {
82        if message.role == Role::Assistant {
83            if !pending_complete(&pending, &completed) {
84                return false;
85            }
86            pending = (!message.tool_calls.is_empty()).then(|| {
87                message
88                    .tool_calls
89                    .iter()
90                    .map(|call| call.id.to_string())
91                    .collect()
92            });
93            completed.clear();
94            continue;
95        }
96
97        if message.role != Role::Tool {
98            if !pending_complete(&pending, &completed) {
99                return false;
100            }
101            pending = None;
102            completed.clear();
103            continue;
104        }
105
106        if let Content::Parts(parts) = &message.content {
107            for part in parts {
108                if let ContentPart::ToolResult { call_id, .. } = part {
109                    if !pending
110                        .as_ref()
111                        .is_some_and(|ids| ids.contains(call_id.as_str()))
112                        || !completed.insert(call_id.to_string())
113                    {
114                        return false;
115                    }
116                }
117            }
118        }
119    }
120
121    pending_complete(&pending, &completed)
122}
123
124#[cfg(test)]
125mod tests {
126    use crate::types::message::{ContentPart, Message, ToolCall};
127
128    fn assistant_call(id: &str) -> Message {
129        let mut message = Message::assistant("calling");
130        message.tool_calls.push(ToolCall {
131            id: id.into(),
132            name: "read".into(),
133            arguments: serde_json::json!({}),
134        });
135        message
136    }
137
138    fn tool_result(id: &str) -> Message {
139        Message::tool(vec![ContentPart::ToolResult {
140            call_id: id.into(),
141            output: "ok".into(),
142            is_error: false,
143        }])
144    }
145
146    #[test]
147    fn groups_tool_transaction_and_trailing_answer_as_one_unit() {
148        let messages = vec![
149            Message::user("question"),
150            assistant_call("call-1"),
151            tool_result("call-1"),
152            Message::assistant("answer"),
153            Message::user("next"),
154            Message::assistant("done"),
155        ];
156
157        assert_eq!(super::unit_boundaries(&messages), vec![0..4, 4..6]);
158    }
159
160    #[test]
161    fn iterative_tool_loop_is_partitioned_without_requiring_new_user_messages() {
162        let messages = vec![
163            Message::user("do the task"),
164            assistant_call("call-1"),
165            tool_result("call-1"),
166            assistant_call("call-2"),
167            tool_result("call-2"),
168            assistant_call("call-3"),
169            tool_result("call-3"),
170            Message::assistant("done"),
171        ];
172
173        assert_eq!(super::unit_boundaries(&messages), vec![0..3, 3..5, 5..8]);
174        for unit in super::unit_boundaries(&messages) {
175            assert!(super::strict_tool_pairing_is_valid(&messages[unit]));
176        }
177    }
178
179    #[test]
180    fn does_not_split_at_user_boundary_while_tool_results_are_missing() {
181        let messages = vec![
182            assistant_call("call-1"),
183            Message::user("provider recovery"),
184            tool_result("call-1"),
185            Message::user("next"),
186        ];
187
188        assert_eq!(super::unit_boundaries(&messages), vec![0..3, 3..4]);
189    }
190
191    #[test]
192    fn empty_history_has_no_units() {
193        assert!(super::unit_boundaries(&[]).is_empty());
194    }
195
196    #[test]
197    fn standalone_tool_messages_are_independent_units() {
198        let messages = vec![tool_result("orphan-1"), tool_result("orphan-2")];
199        assert_eq!(super::unit_boundaries(&messages), vec![0..1, 1..2]);
200    }
201
202    #[test]
203    fn strict_pairing_validator_matches_provider_contract() {
204        let valid = vec![assistant_call("call-1"), tool_result("call-1")];
205        assert!(super::strict_tool_pairing_is_valid(&valid));
206        assert!(!super::strict_tool_pairing_is_valid(&valid[..1]));
207        assert!(!super::strict_tool_pairing_is_valid(&[tool_result(
208            "orphan"
209        )]));
210    }
211}