Skip to main content

vtcode_core/core/agent/
completion.rs

1use crate::core::agent::session::AgentSessionState;
2use crate::llm::provider::MessageRole;
3
4/// Successful tracker mutations establish adoption within the caller's current
5/// request history. Listing, rejected calls, and unrelated tool results do not.
6pub fn tracker_was_adopted(history: &[crate::llm::provider::Message]) -> bool {
7    let mut pending_tracker_calls = std::collections::HashMap::new();
8    for message in history {
9        if message.role == MessageRole::Assistant {
10            // Call IDs can be reused between batches; never match an unanswered
11            // tracker request to a later batch's result.
12            pending_tracker_calls.clear();
13            for call in message.tool_calls.iter().flatten() {
14                let Some(function) = call
15                    .function
16                    .as_ref()
17                    .filter(|function| function.name == crate::config::constants::tools::TASK_TRACKER)
18                else {
19                    continue;
20                };
21                let Ok(args) = serde_json::from_str::<serde_json::Value>(&function.arguments) else {
22                    continue;
23                };
24                if matches!(args.get("action").and_then(serde_json::Value::as_str), Some("create" | "update" | "add")) {
25                    pending_tracker_calls.insert(call.id.as_str(), args);
26                }
27            }
28        } else if message.role == MessageRole::Tool
29            && let Some(args) = message.tool_call_id.as_deref().and_then(|id| pending_tracker_calls.remove(id))
30            && let Ok(output) = serde_json::from_str::<serde_json::Value>(message.content.as_text().as_ref())
31            && tracker_adoption_succeeded(crate::config::constants::tools::TASK_TRACKER, &args, &output)
32        {
33            return true;
34        }
35    }
36    false
37}
38
39/// Observe adoption before tool output or conversation history is compacted.
40pub fn tracker_adoption_succeeded(tool_name: &str, args: &serde_json::Value, output: &serde_json::Value) -> bool {
41    use serde_json::Value;
42    tool_name == crate::config::constants::tools::TASK_TRACKER
43        && output.get("error").is_none()
44        && output.get("success").and_then(Value::as_bool) != Some(false)
45        && output.get("blocked").and_then(Value::as_bool) != Some(true)
46        && output.get("not_executed").and_then(Value::as_bool) != Some(true)
47        && matches!(
48            (args.get("action").and_then(Value::as_str), output.get("status").and_then(Value::as_str)),
49            (Some("create"), Some("created" | "replaced" | "unchanged"))
50                | (Some("update"), Some("updated" | "unchanged"))
51                | (Some("add"), Some("added"))
52        )
53}
54
55/// True when assistant text is a genuine safety/permission handoff that must
56/// not be auto-continued, even if `task_tracker` still has incomplete steps.
57///
58/// Shared by the binary outer-loop Completed queue and AgentRunner status
59/// continuation so both surfaces use the same vocabulary. Safety/policy
60/// denials are evaluated first so a recap that mentions both a budget and a
61/// policy denial still counts as a handoff. Pure budget/recovery recaps are
62/// not handoffs (outer/in-turn recoverable classifiers treat those as continue).
63pub fn tracker_final_text_is_safety_handoff(text: &str) -> bool {
64    let lower = text.to_ascii_lowercase();
65    tracker_final_text_is_policy_handoff(text)
66        || lower.contains("verification is still pending")
67        || lower.contains("unverified assistant responses")
68        || lower.contains("anti-blind")
69        || lower.contains("verification gate")
70}
71
72/// Permission, policy, safety, and credential handoffs remain terminal even
73/// when a pending-verification block can recover with fresh execution budget.
74/// Verification wording is handled separately by the turn's recovery gate.
75pub fn tracker_final_text_is_policy_handoff(text: &str) -> bool {
76    let lower = text.to_ascii_lowercase();
77    if lower.trim().is_empty() {
78        return false;
79    }
80    // True handoffs win even when the text also mentions a budget.
81    if lower.contains("permission denied")
82        || lower.contains("access denied")
83        || lower.contains("safety fuse")
84        || lower.contains("tool-call safety fuse")
85        || lower.contains("policy block")
86        || lower.contains("blocked by policy")
87        || lower.contains("requires manual intervention")
88        || lower.contains("missing credentials")
89        || lower.contains("credentials are missing")
90        || lower.contains("denied by policy")
91        || lower.contains("denied by workspace tool policy")
92        || lower.contains("denied by tool policy")
93        || lower.contains("execution denied by policy")
94        || lower.contains("blocked by tool policy")
95    {
96        return true;
97    }
98    // Pure budget/recovery recaps ("blocked by turn budget", "tool loop
99    // budget exhausted", …) are not user handoffs.
100    false
101}
102
103/// Shared recoverable status-recap vocabulary for tracker continuation.
104///
105/// Used by the binary in-turn override and outer blocked-reason classifier
106/// tests so session/production budget phrases cannot drift between surfaces.
107/// Lowercase input expected.
108pub fn recoverable_status_recap_phrasing(lower: &str) -> bool {
109    lower.contains("turn budget")
110        || lower.contains("preview budget")
111        || lower.contains("tool preview budget")
112        || lower.contains("wall clock")
113        || lower.contains("safety cap")
114        || lower.contains("recovery fallback")
115        || lower.contains("recovery could not confirm")
116        || lower.contains("recovery exhausted")
117        || lower.contains("recovery was exhausted")
118        || lower.contains("tool budget")
119        || lower.contains("tool loop")
120        || lower.contains("tool-call budget")
121        || lower.contains("tool follow-up")
122        || lower.contains("read cap")
123        || lower.contains("work budget")
124        || lower.contains("max tool")
125        || lower.contains("per-turn tool")
126        || lower.contains("budget exhausted")
127        || lower.contains("budget ran out")
128}
129
130/// True when final assistant text asks the user for a decision/confirmation.
131///
132/// Shared by outer tracker auto-queue and in-turn continuation so Completed
133/// turns that end with a genuine question are not auto-continued past the ask.
134pub fn tracker_final_text_requires_user_input(text: &str) -> bool {
135    let trimmed = text.trim();
136    if trimmed.is_empty() {
137        return false;
138    }
139    if trimmed.ends_with('?') {
140        return true;
141    }
142    let lower = trimmed.to_ascii_lowercase();
143    const STRONG: &[&str] = &[
144        "please provide",
145        "please confirm",
146        "please approve",
147        "need your approval",
148        "need your permission",
149        "need your decision",
150        "need you to choose",
151        "need you to confirm",
152        "waiting for your",
153        "awaiting your",
154        "your choice",
155        "your decision",
156        "need approval",
157        "requires approval",
158        "approval is required",
159        "need permission",
160        "requires permission",
161        "permission is required",
162        "grant permission",
163        "authorize this",
164        "need a decision",
165        "need clarification",
166        "waiting for input",
167        "awaiting input",
168        "waiting on you",
169        "how should i proceed",
170        "what should i do",
171        "what would you like",
172        "how would you like",
173        "do you want me to",
174    ];
175    const CLAUSE_START: &[&str] = &["could you ", "can you ", "shall i ", "should i "];
176    if STRONG.iter().any(|p| lower.contains(p)) {
177        return true;
178    }
179    CLAUSE_START
180        .iter()
181        .any(|p| lower.trim_start().starts_with(p) || lower.contains(&format!("\n{p}")))
182}
183
184/// Checks if the agent's response is a candidate for completion handling.
185pub fn check_completion_candidate(response_text: &str) -> bool {
186    // High-confidence terminal markers that strongly indicate intent to stop.
187    const COMPLETION_SENTENCES: &[&str] = &[
188        "the task is complete",
189        "task is complete",
190        "task has been completed",
191        "i have successfully completed the task",
192        "work is finished",
193        "operation successful",
194        "i am done",
195        "no more actions needed",
196        "successfully accomplished",
197        "task is now complete",
198        "everything is finished",
199        "i've finished the task",
200        "all requested changes have been applied",
201        "i have finished all the work",
202    ];
203
204    // Lower-confidence markers that need to be at the core of the message.
205    const SOFT_INDICATORS: &[&str] = &[
206        "task completed",
207        "task done",
208        "all done",
209        "finished.",
210        "complete.",
211        "done.",
212    ];
213
214    const UNRESOLVED_PHRASES: &[&str] = &[
215        "still need to",
216        "remaining step",
217        "remaining work",
218        "verification pending",
219        "verification still pending",
220        "tests not run",
221        "haven't run",
222        "have not run",
223        "blocked on",
224        "open questions remain",
225        "question remains",
226        "todo:",
227        "not complete yet",
228        "once verification",
229        "after verification",
230    ];
231
232    let response_lower = response_text.to_lowercase();
233
234    if UNRESOLVED_PHRASES.iter().any(|phrase| response_lower.contains(phrase))
235        || structured_contract_has_unresolved_sections(response_text)
236    {
237        return false;
238    }
239
240    // Strategy 1: Explicit terminal sentences
241    if COMPLETION_SENTENCES.iter().any(|&s| response_lower.contains(s)) {
242        return true;
243    }
244
245    // Strategy 2: Soft indicators that appear at the very end or are the entire message
246    let trimmed = response_lower.trim();
247    for &indicator in SOFT_INDICATORS {
248        if trimmed.ends_with(indicator) || trimmed == indicator {
249            // Heuristic: Ensure it's not "I will soon have the task completed"
250            // Check if preceded by future-tense markers within the same sentence
251            let sentences: Vec<_> = trimmed.split(['.', '!', '?']).collect();
252            if let Some(last_sentence) = sentences.last() {
253                let ls = last_sentence.trim();
254                if ls.contains(indicator)
255                    && !ls.contains("will")
256                    && !ls.contains("going to")
257                    && !ls.contains("about to")
258                    && !ls.contains("once")
259                    && !ls.contains("after")
260                {
261                    return true;
262                }
263            }
264        }
265    }
266
267    // Strategy 3: Structured subagent markdown contract output.
268    // When the model produces the canonical "## Summary / ## Facts / ..." contract, it has
269    // finished its task even without an explicit done phrase.  Detect this by checking that
270    // the response opens with a "## Summary" heading (after stripping leading whitespace) and
271    // also contains a "## Facts" section.  Headers are matched line-by-line after trimming so
272    // CRLF, extra spaces, and capitalisation variations are handled uniformly.
273    {
274        let mut has_summary_header = false;
275        let mut has_facts_header = false;
276        for line in response_text.lines() {
277            let line_lower = line.trim().to_lowercase();
278            if line_lower == "## summary" || line_lower == "# summary" {
279                has_summary_header = true;
280            }
281            if line_lower == "## facts" || line_lower == "# facts" {
282                has_facts_header = true;
283            }
284            if has_summary_header && has_facts_header {
285                return true;
286            }
287        }
288    }
289
290    false
291}
292
293fn structured_contract_has_unresolved_sections(response_text: &str) -> bool {
294    let mut in_open_questions = false;
295    let mut in_verification = false;
296
297    for line in response_text.lines() {
298        let line_lower = line.trim().to_lowercase();
299        if line_lower.starts_with('#') {
300            in_open_questions = line_lower == "## open questions" || line_lower == "# open questions";
301            in_verification = line_lower == "## verification" || line_lower == "# verification";
302            continue;
303        }
304
305        if line_lower.is_empty() {
306            continue;
307        }
308
309        if in_open_questions && !section_entry_is_none(&line_lower) {
310            return true;
311        }
312
313        if in_verification && section_entry_is_unresolved(&line_lower) {
314            return true;
315        }
316    }
317
318    false
319}
320
321fn section_entry_is_none(line: &str) -> bool {
322    let normalized = normalized_section_entry(line).trim_end_matches('.');
323    matches!(normalized, "none" | "n/a")
324}
325
326fn section_entry_is_unresolved(line: &str) -> bool {
327    let normalized = normalized_section_entry(line);
328    normalized.contains("pending")
329        || normalized.contains("not run")
330        || normalized.contains("failed")
331        || normalized.contains("blocked")
332}
333
334fn normalized_section_entry(line: &str) -> &str {
335    line.trim_start_matches(['-', '*']).trim()
336}
337
338/// Check for repetitive text in assistant responses to catch non-tool-calling loops.
339/// Returns true if a loop is detected.
340pub fn check_for_response_loop(response_text: &str, session_state: &mut AgentSessionState) -> bool {
341    if response_text.len() < 10 {
342        return false;
343    }
344
345    // Simplistic check: is this response identical to the last one (ignoring whitespace)?
346    let normalized_current = response_text.split_whitespace().collect::<Vec<_>>().join(" ");
347
348    let repeated = session_state
349        .messages
350        .iter()
351        .rev()
352        .filter(|m| m.role == MessageRole::Assistant)
353        .skip(1)
354        .take(2)
355        .any(|m| {
356            let normalized_prev = m.content.as_text().split_whitespace().collect::<Vec<_>>().join(" ");
357            normalized_prev == normalized_current
358        });
359
360    if repeated {
361        let warning = "Repetitive assistant response detected. Breaking potential loop.".to_string();
362        session_state.push_warning(warning);
363        session_state.consecutive_idle_turns = session_state.consecutive_idle_turns.saturating_add(1);
364        return true;
365    }
366
367    false
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373    use crate::llm::provider::Message;
374
375    #[test]
376    fn policy_handoffs_are_distinct_from_pending_verification_recaps() {
377        for text in [
378            "Verification is still pending after the tool-call budget was exhausted.",
379            "Repeated unverified assistant responses reached the anti-blind verification gate.",
380        ] {
381            assert!(tracker_final_text_is_safety_handoff(text));
382            assert!(!tracker_final_text_is_policy_handoff(text));
383        }
384        for text in [
385            "Verification is still pending; permission denied.",
386            "Tool budget exhausted; blocked by tool policy.",
387            "Missing credentials for the verifier.",
388            "Tool-call safety fuse stopped verification.",
389        ] {
390            assert!(tracker_final_text_is_policy_handoff(text));
391            assert!(tracker_final_text_is_safety_handoff(text));
392        }
393    }
394
395    #[test]
396    fn tracker_final_text_is_safety_handoff_vocabulary() {
397        assert!(tracker_final_text_is_safety_handoff(
398            "Permission denied for exec_command. Next step: retry after access is granted."
399        ));
400        assert!(tracker_final_text_is_safety_handoff(
401            "I hit the tool-call safety fuse mid-verification; policy block."
402        ));
403        assert!(tracker_final_text_is_safety_handoff(
404            "Blocked action: exec_command is denied by workspace tool policy."
405        ));
406        assert!(tracker_final_text_is_safety_handoff(
407            "Turn blocked after repeated unverified assistant responses; verification is still pending."
408        ));
409        assert!(tracker_final_text_is_safety_handoff(
410            "Anti-blind checkpoint remains; verification is still pending."
411        ));
412        assert!(!tracker_final_text_is_safety_handoff(
413            "## Status\nBlocked by turn budget. Next step: read design/diff.rs."
414        ));
415        assert!(!tracker_final_text_is_safety_handoff(
416            "## Status\nBlocked by turn tool budget. Next step: patch the struct."
417        ));
418        assert!(!tracker_final_text_is_safety_handoff(
419            "Tool loop budget exhausted; continuing next turn with remaining tracker steps."
420        ));
421        assert!(!tracker_final_text_is_safety_handoff(
422            "Fix 1 partially applied, needs verification; next step is cargo check."
423        ));
424        // Bare tool-policy mention is not a handoff; explicit denial still is.
425        assert!(!tracker_final_text_is_safety_handoff("Reviewed tool policy docs; next step is the tracker patch."));
426        assert!(tracker_final_text_is_safety_handoff(
427            "Blocked action: exec_command is denied by workspace tool policy."
428        ));
429        assert!(!tracker_final_text_is_safety_handoff("Implemented patch apply; verification passed."));
430        assert!(!tracker_final_text_is_safety_handoff(""));
431        // Policy denial wins even when a budget is also mentioned.
432        assert!(tracker_final_text_is_safety_handoff("Tool budget exhausted; denied by policy for exec_command."));
433        assert!(tracker_final_text_is_safety_handoff("Turn budget hit, then permission denied for the write."));
434    }
435
436    #[test]
437    fn recoverable_status_recap_phrasing_covers_session_budget_recaps() {
438        for phrase in [
439            "blocked by the turn's preview budget",
440            "tool budget ran out",
441            "hit the per-file read cap",
442            "preview budget exhausted",
443            "tool loop budget exhausted",
444            "recovery fallback",
445            "reached the safety cap",
446            "work budget exhausted",
447        ] {
448            assert!(
449                recoverable_status_recap_phrasing(&phrase.to_ascii_lowercase()),
450                "must treat as recoverable: {phrase}"
451            );
452        }
453        for phrase in [
454            "permission denied for exec_command",
455            "verification is still pending",
456            "needs a user decision",
457        ] {
458            assert!(
459                !recoverable_status_recap_phrasing(&phrase.to_ascii_lowercase()),
460                "must not treat as recoverable budget phrasing: {phrase}"
461            );
462        }
463    }
464
465    #[test]
466    fn tracker_final_text_requires_user_input_vocabulary() {
467        assert!(tracker_final_text_requires_user_input("Step 1 done. Which branch should I use for step 2?"));
468        assert!(tracker_final_text_requires_user_input(
469            "## Status\nPlease confirm whether to use the stable schema."
470        ));
471        assert!(tracker_final_text_requires_user_input("Can you confirm the migration path before I edit."));
472        assert!(!tracker_final_text_requires_user_input(
473            "## Status\nNext we need your workspace path in CONFIG; patching the struct now."
474        ));
475        assert!(!tracker_final_text_requires_user_input("Blocked by turn budget. Next step: read helpers.rs."));
476        assert!(!tracker_final_text_requires_user_input(""));
477    }
478
479    #[test]
480    fn test_completion_candidates() {
481        assert!(check_completion_candidate("The task is complete"));
482        assert!(check_completion_candidate("Revision 1: task is complete."));
483        assert!(check_completion_candidate("I have successfully completed the task."));
484        assert!(check_completion_candidate("Task done"));
485        assert!(check_completion_candidate("All done"));
486
487        // Negative cases
488        assert!(!check_completion_candidate("I will have the task done soon"));
489        assert!(!check_completion_candidate("Is the task done?"));
490        assert!(!check_completion_candidate("random text"));
491        assert!(!check_completion_candidate("The task is complete once verification finishes."));
492        assert!(!check_completion_candidate("All done. Verification pending."));
493        assert!(!check_completion_candidate("All done, but open questions remain."));
494    }
495
496    #[test]
497    fn subagent_markdown_contract_detected_as_complete() {
498        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";
499        assert!(check_completion_candidate(contract));
500    }
501
502    #[test]
503    fn subagent_markdown_contract_with_crlf_detected_as_complete() {
504        let contract = "## Summary\r\n- Done.\r\n\r\n## Facts\r\n- Fact 1.\r\n";
505        assert!(check_completion_candidate(contract));
506    }
507
508    #[test]
509    fn subagent_markdown_contract_with_leading_whitespace_detected() {
510        let contract = "\n\n## Summary\n- item\n\n## Facts\n- fact\n";
511        assert!(check_completion_candidate(contract));
512    }
513
514    #[test]
515    fn document_with_only_summary_header_not_detected() {
516        let doc = "## Summary\n- This is a doc without a Facts section.\n";
517        assert!(!check_completion_candidate(doc));
518    }
519
520    #[test]
521    fn document_with_only_facts_header_not_detected() {
522        let doc = "## Facts\n- Fact without summary.\n";
523        assert!(!check_completion_candidate(doc));
524    }
525
526    #[test]
527    fn structured_contract_with_open_questions_is_not_complete() {
528        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.";
529        assert!(!check_completion_candidate(doc));
530    }
531
532    #[test]
533    fn structured_contract_with_unresolved_verification_is_not_complete() {
534        let doc = "## Summary\n- Work applied.\n\n## Facts\n- Fact.\n\n## Verification\n- Verification pending.\n\n## Open Questions\n- None";
535        assert!(!check_completion_candidate(doc));
536    }
537
538    #[test]
539    fn structured_contract_with_none_punctuation_is_complete() {
540        let doc = "## Summary\n- Work applied.\n\n## Facts\n- Fact.\n\n## Verification\n- Process confirmed.\n\n## Open Questions\n- None.";
541        assert!(check_completion_candidate(doc));
542    }
543
544    #[test]
545    fn response_loop_ignores_current_assistant_message() {
546        let repeated_response = "The task is complete";
547        let mut state = AgentSessionState::new("session".to_string(), 8, 4, 128_000);
548        state.messages_mut().push(Message::assistant(repeated_response.to_string()));
549
550        assert!(!check_for_response_loop(repeated_response, &mut state));
551    }
552
553    #[test]
554    fn response_loop_still_detects_prior_duplicate_assistant_message() {
555        let repeated_response = "The task is complete";
556        let mut state = AgentSessionState::new("session".to_string(), 8, 4, 128_000);
557        state.messages_mut().push(Message::assistant(repeated_response.to_string()));
558        state.messages_mut().push(Message::assistant(repeated_response.to_string()));
559
560        assert!(check_for_response_loop(repeated_response, &mut state));
561    }
562}