use vtcode_core::llm::provider as uni;
pub(super) const AUTONOMOUS_CONTINUE_DIRECTIVE: &str = "The last reply did not finish the task, so the turn continues. \
Take the next concrete action, then report the result or what is blocking it.";
pub(super) const MAX_CONSECUTIVE_RELAXED_CONTINUATIONS: u32 = 3;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct InterimTextContinuationDecision {
pub(super) should_continue: bool,
pub(super) reason: &'static str,
pub(super) is_interim_progress: bool,
pub(super) last_user_follow_up: bool,
pub(super) recent_tool_activity: bool,
pub(super) last_user_requested_progressive_work: bool,
pub(super) is_relaxed_continuation: bool,
}
impl InterimTextContinuationDecision {
fn with(
should_continue: bool,
reason: &'static str,
is_interim_progress: bool,
last_user_follow_up: bool,
recent_tool_activity: bool,
last_user_requested_progressive_work: bool,
) -> Self {
Self {
should_continue,
reason,
is_interim_progress,
last_user_follow_up,
recent_tool_activity,
last_user_requested_progressive_work,
is_relaxed_continuation: false,
}
}
fn with_relaxed(
should_continue: bool,
reason: &'static str,
is_interim_progress: bool,
last_user_follow_up: bool,
recent_tool_activity: bool,
last_user_requested_progressive_work: bool,
) -> Self {
Self {
should_continue,
reason,
is_interim_progress,
last_user_follow_up,
recent_tool_activity,
last_user_requested_progressive_work,
is_relaxed_continuation: true,
}
}
}
pub(super) fn evaluate_interim_text_continuation(
full_auto: bool,
planning_active: bool,
history: &[uni::Message],
text: &str,
consecutive_relaxed_continuations: u32,
) -> InterimTextContinuationDecision {
let is_interim_progress = is_interim_progress_update(text);
let lower = text.to_ascii_lowercase();
let last_user_follow_up = last_user_message_is_follow_up(history);
let recent_tool_activity = has_recent_tool_activity(history);
let last_user_requested_progressive_work = last_user_requested_progressive_work(history);
let read_only_request = last_user_requested_read_only_answer(history);
let d = |should_continue: bool, reason: &'static str| {
InterimTextContinuationDecision::with(
should_continue,
reason,
is_interim_progress,
last_user_follow_up,
recent_tool_activity,
last_user_requested_progressive_work,
)
};
let d_relaxed = |should_continue: bool, reason: &'static str| {
InterimTextContinuationDecision::with_relaxed(
should_continue,
reason,
is_interim_progress,
last_user_follow_up,
recent_tool_activity,
last_user_requested_progressive_work,
)
};
if planning_active {
return d(false, "planning_active");
}
if full_auto {
let asks_user_input = text.contains('?') || contains_user_input_request(&lower);
if text.trim().is_empty() {
return d(false, "full_auto_empty_response");
}
if asks_user_input {
return d(false, "full_auto_user_input_handoff");
}
if lower.contains("verification is still pending")
|| lower.contains("unverified assistant responses")
|| vtcode_core::core::agent::completion::tracker_final_text_is_safety_handoff(text)
{
return d(false, "full_auto_safety_handoff");
}
if has_explicit_blocker(&lower) {
return d(false, "full_auto_blocked_handoff");
}
if read_only_request {
return d(false, "full_auto_read_only_answer");
}
if full_auto_response_is_conclusive(&lower) {
return d(false, "full_auto_final_completion");
}
}
if !is_interim_progress {
let not_conclusive = !last_clause_contains_conclusive_marker(&lower);
let has_relaxed_continuation_intent = has_relaxed_continuation_intent(&lower);
let asks_user_question = text.contains('?') || contains_user_input_request(&lower);
let relaxed_budget_exhausted = consecutive_relaxed_continuations >= MAX_CONSECUTIVE_RELAXED_CONTINUATIONS;
if !asks_user_question
&& !relaxed_budget_exhausted
&& not_conclusive
&& has_relaxed_continuation_intent
&& recent_tool_activity
{
return d_relaxed(true, "recent_tool_activity_relaxed");
}
if !asks_user_question
&& !relaxed_budget_exhausted
&& not_conclusive
&& has_relaxed_continuation_intent
&& last_user_requested_progressive_work
{
return d_relaxed(true, "progressive_relaxed");
}
if full_auto {
if relaxed_budget_exhausted {
return d(false, "full_auto_relaxed_cap");
}
return d_relaxed(true, "full_auto_continuation");
}
return d(false, "non_interim_text");
}
if last_user_follow_up {
return d(true, "follow_up_prompt");
}
if recent_tool_activity {
return d(true, "recent_tool_activity");
}
if last_user_requested_progressive_work {
return d(true, "progressive_request");
}
if full_auto {
return d(true, "full_auto_continuation");
}
d(false, "interactive_mode")
}
fn recoverable_tracker_budget_phrasing(lower: &str) -> bool {
vtcode_core::core::agent::completion::recoverable_status_recap_phrasing(lower)
}
fn tracker_incomplete_text_is_user_handoff(text: &str) -> bool {
let trimmed = text.trim();
if trimmed.is_empty() {
return true;
}
if trimmed.ends_with('?') {
return true;
}
let lower = trimmed.to_ascii_lowercase();
const STRONG_HANDOFF: &[&str] = &[
"please provide",
"please confirm",
"please approve",
"need your approval",
"need your permission",
"need your decision",
"need you to choose",
"need you to confirm",
"waiting for your",
"awaiting your",
"your choice",
"your decision",
"need approval",
"requires approval",
"require approval",
"approval is required",
"need permission",
"requires permission",
"require permission",
"permission is required",
"grant permission",
"authorize this",
"need a decision",
"need clarification",
"waiting for input",
"awaiting input",
"waiting on you",
"how should i proceed",
"what should i do",
"what would you like",
"how would you like",
"do you want me to",
];
const CLAUSE_START_HANDOFF: &[&str] = &["could you ", "can you ", "shall i ", "should i "];
if STRONG_HANDOFF.iter().any(|pattern| lower.contains(pattern)) {
return true;
}
CLAUSE_START_HANDOFF
.iter()
.any(|pattern| lower.trim_start().starts_with(pattern) || lower.contains(&format!("\n{pattern}")))
}
pub(super) fn apply_tracker_continuation_override(
mut decision: InterimTextContinuationDecision,
tracker_incomplete: bool,
planning_active: bool,
text: &str,
) -> InterimTextContinuationDecision {
if !tracker_incomplete || planning_active {
return decision;
}
if vtcode_core::core::agent::completion::tracker_final_text_is_safety_handoff(text)
|| text.to_ascii_lowercase().contains("verification is still pending")
|| text.to_ascii_lowercase().contains("unverified assistant responses")
{
decision.should_continue = false;
decision.reason = "tracker_safety_handoff";
decision.is_relaxed_continuation = false;
return decision;
}
if decision.should_continue {
return decision;
}
if tracker_incomplete_text_is_user_handoff(text) {
return decision;
}
let lower = text.to_ascii_lowercase();
let explicit_safety_handoff = lower.contains("safety fuse")
|| lower.contains("permission denied")
|| lower.contains("access denied")
|| lower.contains("requires manual intervention")
|| lower.contains("missing credentials")
|| lower.contains("credentials are missing")
|| lower.contains("policy block")
|| lower.contains("blocked by policy")
|| lower.contains("denied by policy")
|| lower.contains("denied by tool policy")
|| lower.contains("denied by workspace tool policy");
if explicit_safety_handoff {
decision.should_continue = false;
decision.reason = "tracker_safety_handoff";
decision.is_relaxed_continuation = false;
return decision;
}
if has_explicit_blocker(&lower) && !recoverable_tracker_budget_phrasing(&lower) {
return decision;
}
decision.should_continue = true;
decision.reason = "tracker_incomplete_continuation";
decision.is_relaxed_continuation = false;
decision
}
pub(super) fn continuation_telemetry_outcome(
full_auto: bool,
decision: &InterimTextContinuationDecision,
) -> &'static str {
if decision.should_continue {
return if full_auto {
"full_auto_continuation"
} else {
"continuation"
};
}
match decision.reason {
"full_auto_user_input_handoff" => "user_input_handoff",
"full_auto_relaxed_cap" => "safety_cap_handoff",
"full_auto_safety_handoff" => "safety_handoff",
"full_auto_blocked_handoff" | "full_auto_empty_response" => "blocked_handoff",
_ => "final_completion",
}
}
pub(super) fn push_system_directive_once(history: &mut Vec<uni::Message>, directive: &str) {
let current_turn_start = history
.iter()
.rposition(|message| message.role == uni::MessageRole::User)
.unwrap_or(0);
let already_present = history
.iter()
.skip(current_turn_start)
.any(|message| message.role == uni::MessageRole::System && message.content.as_text().trim() == directive);
if !already_present {
history.push(uni::Message::system(directive.to_string()));
}
}
fn last_clause_contains_conclusive_marker(lower: &str) -> bool {
let conclusive_markers = [
"completed",
"done",
"fixed",
"resolved",
"summary",
"final review",
"final blocker",
"next action",
"what changed",
"validation",
"passed",
"passes",
"cannot proceed",
"can't proceed",
"blocked by",
"all set",
];
let last_clause = lower
.char_indices()
.rfind(|(_, ch)| ['.', '!', '\n', '—', '…'].contains(ch))
.map(|(idx, ch)| lower[idx + ch.len_utf8()..].trim_start())
.unwrap_or(lower);
conclusive_markers.iter().any(|marker| last_clause.contains(marker))
}
fn full_auto_response_is_conclusive(lower: &str) -> bool {
let conclusive_markers = [
"completed",
"complete",
"done",
"fixed",
"resolved",
"summary",
"result summary",
"final review",
"final blocker",
"next action",
"what changed",
"validation",
"validated",
"passed",
"passes",
"finished",
"successful",
"successfully",
"no issues",
"no errors",
"nothing to fix",
"all set",
"that's all",
"that’s all",
];
let terminal_text =
lower.trim_end_matches(|ch| ['.', '!', '\n', '—', '…', '`', '*', '_', ')', ']', '}'].contains(&ch));
let last_non_empty_clause = terminal_text
.split(|ch| ['.', '!', '\n', '—', '…'].contains(&ch))
.rfind(|clause| !clause.trim().is_empty())
.unwrap_or(terminal_text)
.trim();
let last_clause_has_continuation_intent =
has_interim_intent_clause(last_non_empty_clause) || has_relaxed_continuation_intent(last_non_empty_clause);
if !last_clause_has_continuation_intent
&& (last_clause_contains_conclusive_marker(terminal_text)
|| conclusive_markers.iter().any(|marker| last_non_empty_clause.contains(marker)))
{
return true;
}
let has_summary_heading = lower.lines().any(|line| {
let line = line.trim().trim_start_matches(['#', '*', '-', '>', ' ']);
["summary:", "result:", "results:", "what changed:", "final answer:"]
.iter()
.any(|marker| line.starts_with(marker))
});
has_summary_heading && !has_interim_intent_clause(lower)
}
fn has_explicit_blocker(lower: &str) -> bool {
[
"i'm blocked",
"im blocked",
"i’m blocked",
"i am blocked",
"is blocked",
"blocked by",
"cannot proceed",
"can't proceed",
"can’t proceed",
"unable to proceed",
"cannot continue",
"can't continue",
"can’t continue",
"unable to continue",
"cannot complete",
"can't complete",
"can’t complete",
"unable to complete",
"no access to",
"permission denied",
"access denied",
"missing credentials",
"credentials are missing",
"not possible to proceed",
"requires manual intervention",
"safety fuse",
"tool-call safety fuse",
"policy block",
"verification is still pending",
"unverified assistant responses",
"anti-blind",
"verification gate",
"turn blocked after",
]
.iter()
.any(|pattern| lower.contains(pattern))
}
pub(super) fn is_interim_progress_update(text: &str) -> bool {
let trimmed = text.trim();
if trimmed.is_empty() || trimmed.len() > 800 {
return false;
}
let lower = trimmed.to_ascii_lowercase();
if !has_interim_intent_clause(&lower) {
return false;
}
if trimmed.contains('?') || contains_user_input_request(&lower) {
return false;
}
!last_clause_contains_conclusive_marker(&lower)
}
fn last_user_message_is_follow_up(history: &[uni::Message]) -> bool {
history
.iter()
.rev()
.find(|message| message.role == uni::MessageRole::User)
.is_some_and(|message| {
crate::agent::runloop::unified::state::is_follow_up_prompt_like(message.content.as_text().as_ref())
})
}
pub(crate) fn tracker_continuation_adoption_allowed(history: &[uni::Message]) -> bool {
let Some((request_start, request_message)) = history.iter().enumerate().rev().find(|(_, message)| {
message.role == uni::MessageRole::User
&& !crate::agent::runloop::unified::turn::is_internal_harness_follow_up(message.content.as_text().as_ref())
}) else {
return false;
};
let request = request_message.content.as_text();
if tracker_request_explicitly_adopts(request.as_ref()) {
return true;
}
vtcode_core::core::agent::completion::tracker_was_adopted(history.get(request_start..).unwrap_or_default())
}
pub(crate) fn tracker_request_explicitly_adopts(request: &str) -> bool {
crate::agent::runloop::unified::state::is_follow_up_prompt_like(request)
|| request.trim() == vtcode_core::prompts::system::PLANNING_WORKFLOW_IMPLEMENTATION_PROMPT
}
fn has_recent_tool_activity(history: &[uni::Message]) -> bool {
history.iter().rev().take(16).any(|message| {
message.role == uni::MessageRole::Tool || message.tool_call_id.is_some() || message.tool_calls.is_some()
})
}
fn last_user_requested_progressive_work(history: &[uni::Message]) -> bool {
let Some(text) = last_user_message_text(history) else {
return false;
};
[
"explore",
"inspect",
"look into",
"investigate",
"debug",
"trace",
"check",
"review",
"analy",
"walk through",
"run ",
"execute",
"format",
"cargo fmt",
"cargo check",
"cargo test",
"fix",
"edit",
"update",
"change",
"modify",
"scan",
"search",
"grep",
"ast-grep",
"find ",
"use vt code",
"semantic code understanding",
"show me how",
]
.iter()
.any(|needle| text.contains(needle))
}
fn last_user_requested_read_only_answer(history: &[uni::Message]) -> bool {
let Some(text) = last_user_message_text(history) else {
return false;
};
let asks_for_information = [
"summarize",
"summary",
"overview",
"explain",
"describe",
"what is",
"what's",
"what are",
"why ",
"how does",
"how do",
"show me",
"tell me",
"compare",
"explore",
"walk me through",
]
.iter()
.any(|needle| text.contains(needle));
if !asks_for_information {
return false;
}
let action_verbs = [
"fix ",
"edit ",
"update ",
"change ",
"modify ",
"create ",
"implement ",
"refactor ",
"write ",
"delete ",
"remove ",
"apply ",
"run ",
"execute ",
"format ",
"test ",
"build ",
];
let action_clause = action_verbs
.iter()
.any(|verb| text.starts_with(verb) || text.match_indices(verb).any(|(index, _)| is_clause_start(&text, index)));
let chained_action = action_verbs.iter().any(|verb| {
[" and ", " then ", "; ", ", "]
.iter()
.any(|connector| text.contains(&format!("{connector}{verb}")))
});
!(action_clause || chained_action)
}
fn has_interim_intent_clause(lower: &str) -> bool {
if clause_has_continuation_intent(lower) {
return true;
}
for (idx, ch) in lower.char_indices() {
if matches!(ch, '.' | '!' | '?' | ':' | ';' | '\n' | '—' | '…') {
let remainder = lower[idx + ch.len_utf8()..].trim_start();
if !remainder.is_empty() && clause_has_continuation_intent(remainder) {
return true;
}
}
}
false
}
fn has_relaxed_continuation_intent(lower: &str) -> bool {
if has_interim_intent_clause(lower) {
return true;
}
[
" let me ",
" i'll ",
" i will ",
" i need to ",
" i want to ",
" i'd like to ",
" next step ",
" next up:",
" continuing ",
" time to ",
]
.iter()
.any(|needle| lower.contains(needle))
}
fn contains_user_input_request(lower: &str) -> bool {
let anywhere_patterns = [
"please provide",
"need your",
"need you to",
"please confirm",
"please let me know",
"waiting for your",
"awaiting your",
"your choice",
"your decision",
"need approval",
"need your approval",
"requires approval",
"require approval",
"approval is required",
"please approve",
"need permission",
"need your permission",
"requires permission",
"require permission",
"permission is required",
"grant permission",
"authorize this",
"need a decision",
"need clarification",
"waiting for input",
"awaiting input",
"waiting on you",
"say the word",
"tell me and i'll",
"let me know if you want",
"if you want me to",
"just ask",
"happy to",
];
if anywhere_patterns.iter().any(|pattern| lower.contains(pattern)) {
return true;
}
let clause_start_patterns = [
"could you",
"can you",
"how would you like",
"what would you like",
"which option",
"would you like me to",
"shall i",
"do you want me to",
"should i",
"how should i proceed",
"what should i do",
"how do you want to proceed",
"how would you like to proceed",
"what would you prefer",
"which approach",
"any suggestions",
"let me know how",
"let me know if",
"let me know what",
"let me know which",
"let me know whether",
"let me know where",
"let me know when",
"let me know why",
"tell me how",
"tell me if",
"tell me what",
"tell me which",
"tell me whether",
"tell me where",
"tell me when",
"tell me why",
];
clause_start_patterns
.iter()
.any(|pattern| contains_phrase_at_clause_start(lower, pattern))
}
fn contains_phrase_at_clause_start(lower: &str, phrase: &str) -> bool {
lower.match_indices(phrase).any(|(idx, _)| is_clause_start(lower, idx))
}
fn is_clause_start(text: &str, idx: usize) -> bool {
for ch in text[..idx].chars().rev() {
if ch == '\n' {
return true;
}
if ch.is_whitespace() {
continue;
}
return matches!(ch, '.' | '!' | '?' | ':' | ';' | ',' | '—' | '…');
}
true
}
fn clause_has_continuation_intent(clause: &str) -> bool {
let clause = clause.trim_start();
if clause.is_empty() {
return false;
}
let normalized = normalize_clause(clause);
if normalized.is_empty() {
return false;
}
core_intent_matches(normalized) || starts_with_present_progress_update(normalized)
}
fn normalize_clause(s: &str) -> &str {
let s = s.trim_start();
let s = s.strip_prefix("now ").unwrap_or(s);
let s = s.strip_prefix("then ").unwrap_or(s);
let s = s.strip_prefix("first, ").unwrap_or(s);
let s = s.strip_prefix("first ").unwrap_or(s);
let s = s.strip_prefix("next, ").unwrap_or(s);
let s = match s.strip_prefix("next ") {
Some(after) if after.starts_with("i ") || after.starts_with("we ") => after,
_ => s,
};
let s = s.strip_prefix("my ").unwrap_or(s);
let s = s.strip_prefix("i am ").unwrap_or(s);
let s = if let Some(after) = s.strip_prefix("i ") {
after
} else if let Some(after) = s.strip_prefix("i").filter(|after| after.starts_with('\'')) {
after
} else {
s
};
let s = s.strip_prefix("we ").unwrap_or(s);
s.trim_start()
}
fn core_intent_matches(text: &str) -> bool {
if text.starts_with("let ") {
return true;
}
const TO_INTENTS: &[&str] = &[
"need to ",
"want to ",
"going to ",
"plan to ",
"intend to ",
"have to ",
"'m going to ",
"'d like to ",
"hope to ",
];
if TO_INTENTS.iter().any(|v| text.starts_with(v)) {
return true;
}
if let Some(rest) = text.strip_prefix("will ").or_else(|| text.strip_prefix("'ll ")) {
return !rest.starts_with("be ") && !rest.starts_with("now be ");
}
if text.starts_with("time to ")
|| text.starts_with("next up:")
|| text.starts_with("next step ")
|| text.starts_with("continuing")
{
return true;
}
false
}
fn starts_with_present_progress_update(lower: &str) -> bool {
let present_progress_prefixes = [
"running ",
"checking ",
"formatting ",
"scanning ",
"inspecting ",
"searching ",
"reading ",
"reviewing ",
"tracing ",
"debugging ",
];
let forward_markers = [
" now",
" then ",
" next ",
" follow-up",
" to confirm",
" to check",
" to verify",
" to inspect",
];
present_progress_prefixes.iter().any(|prefix| lower.starts_with(prefix))
&& forward_markers.iter().any(|marker| lower.contains(marker))
}
fn last_user_message_text(history: &[uni::Message]) -> Option<String> {
history
.iter()
.rev()
.find(|message| message.role == uni::MessageRole::User)
.map(|message| message.content.as_text().to_ascii_lowercase())
}
#[cfg(test)]
mod tests;