Skip to main content

vtcode_core/core/agent/
completion.rs

1use crate::core::agent::session::AgentSessionState;
2use crate::llm::provider::MessageRole;
3
4/// Checks if the agent's response is a candidate for completion handling.
5pub fn check_completion_candidate(response_text: &str) -> bool {
6    // High-confidence terminal markers that strongly indicate intent to stop.
7    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    // Lower-confidence markers that need to be at the core of the message.
25    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    // Strategy 1: Explicit terminal sentences
63    if COMPLETION_SENTENCES
64        .iter()
65        .any(|&s| response_lower.contains(s))
66    {
67        return true;
68    }
69
70    // Strategy 2: Soft indicators that appear at the very end or are the entire message
71    let trimmed = response_lower.trim();
72    for &indicator in SOFT_INDICATORS {
73        if trimmed.ends_with(indicator) || trimmed == indicator {
74            // Heuristic: Ensure it's not "I will soon have the task completed"
75            // Check if preceded by future-tense markers within the same sentence
76            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    // Strategy 3: Structured subagent markdown contract output.
93    // When the model produces the canonical "## Summary / ## Facts / ..." contract, it has
94    // finished its task even without an explicit done phrase.  Detect this by checking that
95    // the response opens with a "## Summary" heading (after stripping leading whitespace) and
96    // also contains a "## Facts" section.  Headers are matched line-by-line after trimming so
97    // CRLF, extra spaces, and capitalisation variations are handled uniformly.
98    {
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
164/// Check for repetitive text in assistant responses to catch non-tool-calling loops.
165/// Returns true if a loop is detected.
166pub 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    // Simplistic check: is this response identical to the last one (ignoring whitespace)?
172    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        // Negative cases
222        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}