use super::*;
pub(crate) const TRACKER_CONTINUE_FOLLOW_UP_PREFIX: &str = "The task tracker still has incomplete steps:";
pub(crate) fn tracker_continue_follow_up(incomplete: &[String]) -> String {
let joined = incomplete.join(", ");
format!(
"{TRACKER_CONTINUE_FOLLOW_UP_PREFIX} {joined}. This follow-up is the harness resuming \
the work, so no user reply is needed. The next step is to continue with the next incomplete step \
using tools and update task_tracker as steps complete. A status-only recap does not advance the \
tracker. The turn can end when the tracker is complete, or when a user decision or a \
permission/policy block stops progress."
)
}
pub(crate) const RECOVERABLE_BLOCKED_CONTINUE_FOLLOW_UP_PREFIX: &str =
"The previous turn ended on a recoverable block:";
pub(crate) fn recoverable_blocked_continue_follow_up(reason: &str) -> String {
format!(
"{RECOVERABLE_BLOCKED_CONTINUE_FOLLOW_UP_PREFIX} {reason} This follow-up is the harness resuming \
the work, so no user reply is needed. Retry the requested work with tools; if a policy block \
repeated, switch to a read-only approach or ask the user. A status-only recap does not advance \
the work. The turn can end when the request is complete, or when a user decision or a \
permission/policy block stops progress."
)
}
pub(crate) fn recoverable_blocked_auto_continue_directive(reason: &str) -> String {
format!(
"Blocked-end auto-continue: the harness queued this turn after a recoverable block ({reason}). \
No user reply is needed. Resume the original request; do not restate a status recap as the answer."
)
}
pub(crate) const TRACKER_RESUME_DIRECTIVE_LABEL: &str = "Resume continuation";
pub(crate) const TRACKER_AUTO_CONTINUE_DIRECTIVE_LABEL: &str = "Tracker auto-continue";
pub(crate) fn tracker_continue_directive(label: &str, incomplete: &[String]) -> String {
format!(
"{label}: task_tracker still has incomplete steps: {}. The harness queued this continuation, so no \
user reply is needed. The next step is the next concrete tracker step; a status-only recap does not \
advance the tracker while work remains.",
incomplete.join(", ")
)
}
pub(crate) const RECOVERABLE_BLOCK_ALLOW_TOKENS: &[&str] = &[
"recovery fallback",
"recovery could not confirm",
"recovery exhausted",
"recovery was exhausted",
"reached the safety cap",
"safety cap",
"preview budget",
"tool preview budget",
"turn budget",
"tool budget",
"tool loop budget",
"tool loop",
"tool-call budget",
"tool follow-up",
"tool-free recovery",
"wall clock",
"blocked due to repeated",
"blocked after repeated",
"without a harness-visible final assistant response",
"blocked tool-call limit",
"recovery tool-call limit",
"consecutive blocked calls",
"tool-call safety limit",
"max tool",
"per-turn tool",
"read cap",
"work budget",
"budget exhausted",
"budget ran out",
];
fn matches_recoverable_block_shape(lower: &str) -> bool {
RECOVERABLE_BLOCK_ALLOW_TOKENS.iter().any(|token| lower.contains(token))
}
fn contains_any_token(lower: &str, tokens: &[&str]) -> bool {
tokens.iter().any(|token| lower.contains(token))
}
const RECOVERABLE_BLOCK_BASE_DENY_TOKENS: &[&str] = &[
"permission",
"user input",
"request_user_input",
"interview",
"verification is still pending",
"unverified assistant responses",
"anti-blind",
"verification gate",
"compaction could not reduce",
"unmatched tool result",
"attempted more tool calls",
"final tool-free synthesis pass",
];
const TRACKER_AUTO_CONTINUE_EXTRA_DENY_TOKENS: &[&str] = &[
"safety fuse",
"manual intervention",
"context exceeded",
"stale recovery state",
"awaiting approval",
"approval-ready plan remains",
];
pub(crate) fn is_budget_exhausted_verification_block(lower: &str) -> bool {
lower.contains("verification is still pending")
&& (lower.contains("tool-call budget")
|| lower.contains("tool budget")
|| lower.contains("turn budget")
|| lower.contains("budget exhausted")
|| lower.contains("budget ran out"))
}
fn verification_block_has_hard_deny(lower: &str, extra_deny: &[&str]) -> bool {
RECOVERABLE_BLOCK_BASE_DENY_TOKENS
.iter()
.filter(|token| {
!matches!(
**token,
"verification is still pending" | "unverified assistant responses" | "anti-blind" | "verification gate"
)
})
.chain(extra_deny.iter())
.any(|token| lower.contains(token))
}
pub(crate) fn is_pending_verification_block(reason: &str) -> bool {
reason.contains(crate::agent::runloop::unified::turn::turn_loop::PENDING_VERIFICATION_BLOCK_REASON)
|| reason
.trim_start()
.to_ascii_lowercase()
.starts_with("verification is still pending:")
}
pub(crate) fn verification_block_allows_auto_recovery(reason: &str, final_text: &str) -> bool {
use vtcode_core::core::agent::completion;
is_pending_verification_block(reason)
&& !verification_block_has_hard_deny(&reason.to_ascii_lowercase(), TRACKER_AUTO_CONTINUE_EXTRA_DENY_TOKENS)
&& !completion::tracker_final_text_is_policy_handoff(final_text)
&& !completion::tracker_final_text_requires_user_input(final_text)
}
pub(crate) fn tracker_auto_continue_is_recoverable_block(reason: Option<&str>) -> bool {
if reason.is_some_and(refusal::is_refusal_notice) {
return false;
}
let Some(reason) = reason.map(str::to_ascii_lowercase) else {
return false;
};
if is_budget_exhausted_verification_block(&reason) {
return !verification_block_has_hard_deny(&reason, TRACKER_AUTO_CONTINUE_EXTRA_DENY_TOKENS);
}
if contains_any_token(&reason, RECOVERABLE_BLOCK_BASE_DENY_TOKENS)
|| contains_any_token(&reason, TRACKER_AUTO_CONTINUE_EXTRA_DENY_TOKENS)
{
return false;
}
matches_recoverable_block_shape(&reason)
}
pub(crate) fn should_queue_tracker_auto_continue(
auto_continue_enabled: bool,
planning_active: bool,
turn_completed: bool,
blocked_reason: Option<&str>,
is_verification_block: bool,
incomplete_items: Option<&[String]>,
cross_turn_turns: u8,
final_text_is_safety_handoff: bool,
final_text_requires_user_input: bool,
) -> bool {
if !auto_continue_enabled || planning_active || cross_turn_turns == 0 {
return false;
}
if final_text_is_safety_handoff || final_text_requires_user_input {
return false;
}
if turn_completed {
return incomplete_items.is_some_and(|items| !items.is_empty());
}
if blocked_reason.is_none() {
return false;
}
if is_verification_block {
return false;
}
tracker_auto_continue_is_recoverable_block(blocked_reason)
}
pub(crate) fn should_queue_tracker_resume_continuation(
auto_continue_enabled: bool,
tracker_adoption_allowed: bool,
cross_turn_turns: u8,
incomplete_items: Option<&[String]>,
) -> bool {
auto_continue_enabled
&& tracker_adoption_allowed
&& cross_turn_turns > 0
&& incomplete_items.is_some_and(|items| !items.is_empty())
}
pub(crate) const MAX_PLAN_EMPTY_FALLBACK_AUTO_CONTINUE: u8 = 2;
pub(crate) fn is_plan_empty_fallback_text(text: &str) -> bool {
text.contains("without a final plan synthesis")
&& text.contains("the next turn can reuse it without re-reading files")
}
pub(crate) fn should_queue_plan_mode_auto_continue(
auto_continue_enabled: bool,
planning_active: bool,
plan_ready_for_approval: bool,
turn_completed: bool,
blocked_reason: Option<&str>,
is_verification_block: bool,
cross_turn_turns: u8,
consecutive_empty_fallbacks: u8,
) -> bool {
if !auto_continue_enabled || !planning_active || cross_turn_turns == 0 {
return false;
}
if plan_ready_for_approval || is_verification_block || turn_completed {
return false;
}
if consecutive_empty_fallbacks >= MAX_PLAN_EMPTY_FALLBACK_AUTO_CONTINUE {
return false;
}
blocked_reason.is_some_and(plan_mode_recoverable_block)
}
pub(crate) fn plan_mode_recoverable_block(reason: &str) -> bool {
if refusal::is_refusal_notice(reason) {
return false;
}
let lower = reason.to_ascii_lowercase();
if is_budget_exhausted_verification_block(&lower) {
return !verification_block_has_hard_deny(&lower, &["awaiting"]);
}
if contains_any_token(&lower, RECOVERABLE_BLOCK_BASE_DENY_TOKENS) || lower.contains("awaiting") {
return false;
}
if matches_recoverable_block_shape(&lower) {
return true;
}
if lower.contains("planning turn ended") || lower.contains("approval-ready plan") {
return false;
}
false
}
pub(crate) fn plan_progress_line(
title: &str,
ready_for_approval: bool,
open_decisions: usize,
step_count: usize,
) -> String {
let label = title.trim();
let name = if label.is_empty() { None } else { Some(label) };
if ready_for_approval {
return match name {
Some(name) if step_count > 0 => format!("• Plan {name} — ready for approval ({step_count} steps)"),
Some(name) => format!("• Plan {name} — ready for approval"),
None if step_count > 0 => format!("• Plan — ready for approval ({step_count} steps)"),
None => "• Plan — ready for approval".to_string(),
};
}
if open_decisions > 0 {
return match name {
Some(name) => format!("• Plan {name} — open decisions: {open_decisions}"),
None => format!("• Plan — open decisions: {open_decisions}"),
};
}
match name {
Some(name) => format!("• Plan {name} — research/synthesis"),
None => "• Plan — research/synthesis".to_string(),
}
}
pub(crate) const PLAN_MODE_AUTO_CONTINUE_MARKER: &str = "Plan-mode auto-continue:";
const PLAN_MODE_CONTINUE_DIRECTIVE_TAIL: &str = "Planning stays active and read-only, so the next step is to continue \
planning: read-only research and synthesis toward one compact `<proposed_plan>`. The harness queued this \
continuation, so no user reply is needed, and code changes wait for plan approval.";
pub(crate) fn plan_mode_continue_follow_up() -> String {
format!(
"{PLAN_MODE_AUTO_CONTINUE_MARKER} no validated persisted plan is ready for approval yet. \
{PLAN_MODE_CONTINUE_DIRECTIVE_TAIL}"
)
}
pub(crate) fn plan_mode_auto_continue_directive() -> String {
format!(
"{PLAN_MODE_AUTO_CONTINUE_MARKER} planning remains active and no validated plan is ready for approval. \
{PLAN_MODE_CONTINUE_DIRECTIVE_TAIL}"
)
}
pub(crate) fn plan_mode_resume_directive() -> String {
format!(
"Resume continuation: planning remains active after a recoverable blocked handoff. \
{PLAN_MODE_CONTINUE_DIRECTIVE_TAIL}"
)
}