vtcode_core/core/agent/
completion.rs1use crate::core::agent::session::AgentSessionState;
2use crate::llm::provider::MessageRole;
3
4pub fn check_completion_candidate(response_text: &str) -> bool {
6 const COMPLETION_SENTENCES: &[&str] = &[
8 "the task is complete",
9 "task is complete",
10 "task has been completed",
11 "i have successfully completed the task",
12 "work is finished",
13 "operation successful",
14 "i am done",
15 "no more actions needed",
16 "successfully accomplished",
17 "task is now complete",
18 "everything is finished",
19 "i've finished the task",
20 "all requested changes have been applied",
21 "i have finished all the work",
22 ];
23
24 const SOFT_INDICATORS: &[&str] = &[
26 "task completed",
27 "task done",
28 "all done",
29 "finished.",
30 "complete.",
31 "done.",
32 ];
33
34 const UNRESOLVED_PHRASES: &[&str] = &[
35 "still need to",
36 "remaining step",
37 "remaining work",
38 "verification pending",
39 "verification still pending",
40 "tests not run",
41 "haven't run",
42 "have not run",
43 "blocked on",
44 "open questions remain",
45 "question remains",
46 "todo:",
47 "not complete yet",
48 "once verification",
49 "after verification",
50 ];
51
52 let response_lower = response_text.to_lowercase();
53
54 if UNRESOLVED_PHRASES
55 .iter()
56 .any(|phrase| response_lower.contains(phrase))
57 || structured_contract_has_unresolved_sections(response_text)
58 {
59 return false;
60 }
61
62 if COMPLETION_SENTENCES
64 .iter()
65 .any(|&s| response_lower.contains(s))
66 {
67 return true;
68 }
69
70 let trimmed = response_lower.trim();
72 for &indicator in SOFT_INDICATORS {
73 if trimmed.ends_with(indicator) || trimmed == indicator {
74 let sentences: Vec<_> = trimmed.split(['.', '!', '?']).collect();
77 if let Some(last_sentence) = sentences.last() {
78 let ls = last_sentence.trim();
79 if ls.contains(indicator)
80 && !ls.contains("will")
81 && !ls.contains("going to")
82 && !ls.contains("about to")
83 && !ls.contains("once")
84 && !ls.contains("after")
85 {
86 return true;
87 }
88 }
89 }
90 }
91
92 {
99 let mut has_summary_header = false;
100 let mut has_facts_header = false;
101 for line in response_text.lines() {
102 let line_lower = line.trim().to_lowercase();
103 if line_lower == "## summary" || line_lower == "# summary" {
104 has_summary_header = true;
105 }
106 if line_lower == "## facts" || line_lower == "# facts" {
107 has_facts_header = true;
108 }
109 if has_summary_header && has_facts_header {
110 return true;
111 }
112 }
113 }
114
115 false
116}
117
118fn structured_contract_has_unresolved_sections(response_text: &str) -> bool {
119 let mut in_open_questions = false;
120 let mut in_verification = false;
121
122 for line in response_text.lines() {
123 let line_lower = line.trim().to_lowercase();
124 if line_lower.starts_with('#') {
125 in_open_questions =
126 line_lower == "## open questions" || line_lower == "# open questions";
127 in_verification = line_lower == "## verification" || line_lower == "# verification";
128 continue;
129 }
130
131 if line_lower.is_empty() {
132 continue;
133 }
134
135 if in_open_questions && !section_entry_is_none(&line_lower) {
136 return true;
137 }
138
139 if in_verification && section_entry_is_unresolved(&line_lower) {
140 return true;
141 }
142 }
143
144 false
145}
146
147fn section_entry_is_none(line: &str) -> bool {
148 let normalized = normalized_section_entry(line).trim_end_matches('.');
149 matches!(normalized, "none" | "n/a")
150}
151
152fn section_entry_is_unresolved(line: &str) -> bool {
153 let normalized = normalized_section_entry(line);
154 normalized.contains("pending")
155 || normalized.contains("not run")
156 || normalized.contains("failed")
157 || normalized.contains("blocked")
158}
159
160fn normalized_section_entry(line: &str) -> &str {
161 line.trim_start_matches(['-', '*']).trim()
162}
163
164pub fn check_for_response_loop(response_text: &str, session_state: &mut AgentSessionState) -> bool {
167 if response_text.len() < 10 {
168 return false;
169 }
170
171 let normalized_current = response_text
173 .split_whitespace()
174 .collect::<Vec<_>>()
175 .join(" ");
176
177 let repeated = session_state
178 .messages
179 .iter()
180 .rev()
181 .filter(|m| m.role == MessageRole::Assistant)
182 .skip(1)
183 .take(2)
184 .any(|m| {
185 let normalized_prev = m
186 .content
187 .as_text()
188 .split_whitespace()
189 .collect::<Vec<_>>()
190 .join(" ");
191 normalized_prev == normalized_current
192 });
193
194 if repeated {
195 let warning =
196 "Repetitive assistant response detected. Breaking potential loop.".to_string();
197 session_state.warnings.push(warning);
198 session_state.consecutive_idle_turns =
199 session_state.consecutive_idle_turns.saturating_add(1);
200 return true;
201 }
202
203 false
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209 use crate::llm::provider::Message;
210
211 #[test]
212 fn test_completion_candidates() {
213 assert!(check_completion_candidate("The task is complete"));
214 assert!(check_completion_candidate("Revision 1: task is complete."));
215 assert!(check_completion_candidate(
216 "I have successfully completed the task."
217 ));
218 assert!(check_completion_candidate("Task done"));
219 assert!(check_completion_candidate("All done"));
220
221 assert!(!check_completion_candidate(
223 "I will have the task done soon"
224 ));
225 assert!(!check_completion_candidate("Is the task done?"));
226 assert!(!check_completion_candidate("random text"));
227 assert!(!check_completion_candidate(
228 "The task is complete once verification finishes."
229 ));
230 assert!(!check_completion_candidate(
231 "All done. Verification pending."
232 ));
233 assert!(!check_completion_candidate(
234 "All done, but open questions remain."
235 ));
236 }
237
238 #[test]
239 fn subagent_markdown_contract_detected_as_complete() {
240 let contract = "## Summary\n- Background subprocess launched; PID 86065.\n\n## Facts\n- Script started at 2026-04-25T08:39:10Z.\n\n## Touched Files\n- None\n\n## Verification\n- Process confirmed.\n\n## Open Questions\n- None";
241 assert!(check_completion_candidate(contract));
242 }
243
244 #[test]
245 fn subagent_markdown_contract_with_crlf_detected_as_complete() {
246 let contract = "## Summary\r\n- Done.\r\n\r\n## Facts\r\n- Fact 1.\r\n";
247 assert!(check_completion_candidate(contract));
248 }
249
250 #[test]
251 fn subagent_markdown_contract_with_leading_whitespace_detected() {
252 let contract = "\n\n## Summary\n- item\n\n## Facts\n- fact\n";
253 assert!(check_completion_candidate(contract));
254 }
255
256 #[test]
257 fn document_with_only_summary_header_not_detected() {
258 let doc = "## Summary\n- This is a doc without a Facts section.\n";
259 assert!(!check_completion_candidate(doc));
260 }
261
262 #[test]
263 fn document_with_only_facts_header_not_detected() {
264 let doc = "## Facts\n- Fact without summary.\n";
265 assert!(!check_completion_candidate(doc));
266 }
267
268 #[test]
269 fn structured_contract_with_open_questions_is_not_complete() {
270 let doc = "## Summary\n- Work applied.\n\n## Facts\n- Fact.\n\n## Verification\n- Process confirmed.\n\n## Open Questions\n- Need to rerun the end-to-end flow.";
271 assert!(!check_completion_candidate(doc));
272 }
273
274 #[test]
275 fn structured_contract_with_unresolved_verification_is_not_complete() {
276 let doc = "## Summary\n- Work applied.\n\n## Facts\n- Fact.\n\n## Verification\n- Verification pending.\n\n## Open Questions\n- None";
277 assert!(!check_completion_candidate(doc));
278 }
279
280 #[test]
281 fn structured_contract_with_none_punctuation_is_complete() {
282 let doc = "## Summary\n- Work applied.\n\n## Facts\n- Fact.\n\n## Verification\n- Process confirmed.\n\n## Open Questions\n- None.";
283 assert!(check_completion_candidate(doc));
284 }
285
286 #[test]
287 fn response_loop_ignores_current_assistant_message() {
288 let repeated_response = "The task is complete";
289 let mut state = AgentSessionState::new("session".to_string(), 8, 4, 128_000);
290 state
291 .messages
292 .push(Message::assistant(repeated_response.to_string()));
293
294 assert!(!check_for_response_loop(repeated_response, &mut state));
295 }
296
297 #[test]
298 fn response_loop_still_detects_prior_duplicate_assistant_message() {
299 let repeated_response = "The task is complete";
300 let mut state = AgentSessionState::new("session".to_string(), 8, 4, 128_000);
301 state
302 .messages
303 .push(Message::assistant(repeated_response.to_string()));
304 state
305 .messages
306 .push(Message::assistant(repeated_response.to_string()));
307
308 assert!(check_for_response_loop(repeated_response, &mut state));
309 }
310}