deepstrike_core/context/
units.rs1use crate::types::message::{Content, ContentPart, Message, Role};
2use std::collections::BTreeSet;
3use std::ops::Range;
4
5pub 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
68pub(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}