vtcode 0.175.1

A Rust-based terminal coding agent with modular architecture supporting multiple LLM providers
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
//! Auto-continue directives: tracker, recoverable-block, and plan-mode follow-ups.

use super::*;

/// Build the model-facing auto-continue follow-up for incomplete tracker work.
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."
    )
}

/// Stable opening for recoverable blocked-end auto-continue when no tracker
/// items remain (or none were created). Session evidence: build/auto turns
/// ending with recovery fallback / blocked-tool fuse parked at `Continue…`.
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."
    )
}

/// Label of the system directive paired with a session-resume tracker continuation.
pub(crate) const TRACKER_RESUME_DIRECTIVE_LABEL: &str = "Resume continuation";
/// Label of the system directive paired with an in-session tracker auto-continue.
pub(crate) const TRACKER_AUTO_CONTINUE_DIRECTIVE_LABEL: &str = "Tracker auto-continue";

/// System directive paired with a queued tracker continuation. Both the
/// session-resume and in-session paths share this wording so the stated
/// consequence (the harness resumed; a recap does not advance the tracker)
/// cannot drift between them.
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(", ")
    )
}

/// Shared recoverable blocked-reason shapes for every mode's auto-continue.
/// Keep this the single allow-list so plan-mode and tracker classifiers cannot
/// drift (fuse / no-final / budget / recovery-fallback wording).
/// Deny lists stay per-mode: evaluation order differs (plan allows first so
/// `PLANNING_COMPLETED_TURN_FALLBACK_REASON` is not shadowed by
/// "approval-ready plan").
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))
}

/// Deny tokens shared by the tracker and plan-mode auto-continue classifiers.
/// They mirror production blocked-reason constants and true-handoff vocabulary:
/// `RECOVERY_CONTRACT_VIOLATION_REASON` ("final tool-free synthesis pass" /
/// "attempted more tool calls"), `PENDING_VERIFICATION_BLOCK_REASON`
/// ("verification is still pending"), `POST_TOOL_CONTEXT_COMPACTION_FAILED_REASON`
/// ("compaction could not reduce"), and UNMATCHED_TOOL_RESULT. When one of those
/// constants' wording changes, update the token here in the same commit — the
/// auto-continue gates classify by substring.
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",
];

/// Tracker auto-continue deny tokens beyond [`RECOVERABLE_BLOCK_BASE_DENY_TOKENS`].
const TRACKER_AUTO_CONTINUE_EXTRA_DENY_TOKENS: &[&str] = &[
    "safety fuse",
    "manual intervention",
    "context exceeded",
    "stale recovery state",
    "awaiting approval",
    "approval-ready plan remains",
];

/// Budget-only verification block: `verification is still pending` caused by
/// tool-call budget exhaustion (fresh turn gets fresh execution budget, so a
/// verifier can run). Session `session-vtcode-20261008T094713Z` blocked on
/// exactly this shape and required manual `continue`; other verification
/// blocks (failing verifier, safety, permission) stay terminal.
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"))
}

/// Hard-handoff signals that veto a budget-exhausted verification auto-retry.
/// Shared by the tracker and plan-mode classifiers so compound reasons cannot
/// auto-queue past a permission/interview wait. `extra_deny` holds
/// mode-specific tokens (tracker extras vs plan-mode's broad `"awaiting"`).
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:")
}

/// Verification recovery owns its separate turn/failure limits. Pending-gate
/// wording must not veto it, but genuine policy and user handoffs still win.
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)
}
/// Whether a blocked/completed turn reason is recoverable for tracker auto-queue
/// (budget/preview/tool-free recovery) rather than a user-input handoff.
///
/// Matches **production blocked-reason constants** (turn_loop / post_tool
/// recovery), not paraphrases. Unknown / missing reasons are not auto-queued
/// when the turn did not complete.
pub(crate) fn tracker_auto_continue_is_recoverable_block(reason: Option<&str>) -> bool {
    // A provider refusal is terminal for the refused request: resending it is
    // refused again. Checked before the substring classifiers because the
    // notice may quote provider or response text containing any token.
    if reason.is_some_and(refusal::is_refusal_notice) {
        return false;
    }
    let Some(reason) = reason.map(str::to_ascii_lowercase) else {
        // Only used when the outer gate already marked the turn Completed.
        // Blocked { reason: None } must not auto-queue.
        return false;
    };
    // Budget-exhausted verification blocks can resume on a fresh turn with
    // fresh execution budget. Still deny when harder handoff signals are
    // present (permission / safety / compaction / contract violation).
    if is_budget_exhausted_verification_block(&reason) {
        return !verification_block_has_hard_deny(&reason, TRACKER_AUTO_CONTINUE_EXTRA_DENY_TOKENS);
    }
    // Deny production constants that must never auto-queue (true handoffs).
    // RECOVERY_CONTRACT_VIOLATION_REASON: "...final tool-free synthesis pass...attempted more tool calls."
    // PENDING_VERIFICATION_BLOCK_REASON: "...verification is still pending."
    // POST_TOOL_CONTEXT_COMPACTION_FAILED_REASON: "context exceeded...compaction could not reduce"
    // STALE_APPROVED_PLAN_PAUSE_BLOCK_REASON: "stale recovery state"
    // UNMATCHED_TOOL_RESULT / planning interview-approval handoffs / permission / safety fuse.
    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)
}

/// Pure gate for outer-loop tracker auto-continue after a turn end.
///
/// `auto_continue_enabled` is the raw kill-switch (do not pre-AND
/// `planning_active` — this gate owns that check).
/// `final_text_is_safety_handoff` is true when the turn's final assistant
/// text is a permission/policy/safety handoff — those never auto-queue, even
/// on `Completed` ends with incomplete tracker work.
/// `final_text_requires_user_input` is true when the final text asks the user
/// a genuine question/decision — Completed ends must not auto-queue past the ask.
///
/// Completed turns require incomplete tracker work. Recoverable **blocked**
/// ends continue even without tracker items: session evidence shows
/// recovery-fallback and blocked-tool fuse turns parking at the user
/// `Continue…` prompt in build/auto when no tracker is active.
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 {
        // The dedicated verifier-first path owns its smaller cross-turn and
        // consecutive-failure limits, including budget-only verification ends.
        return false;
    }
    tracker_auto_continue_is_recoverable_block(blocked_reason)
}

/// Pure gate for resume auto-queue of incomplete tracker work.
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())
}

/// Pure gate for plan-mode outer auto-continue.
///
/// Continues incomplete planning only on recoverable **blocked** ends when the
/// plan is not yet ready for approval. Ordinary completed planning turns are
/// never auto-continued (they may be interview or approval handoffs). Never
/// auto-approves.
///
/// `consecutive_empty_fallbacks` breaks the empty-turn self-loop: turns that
/// end with the deterministic `PLANNING_COMPLETED_FALLBACK_RESPONSE` (no LLM
/// synthesis, no tool activity) must not re-queue forever. After
/// [`MAX_PLAN_EMPTY_FALLBACK_AUTO_CONTINUE`] consecutive empties the gate
/// closes and the user must `continue` manually.
pub(crate) const MAX_PLAN_EMPTY_FALLBACK_AUTO_CONTINUE: u8 = 2;

/// Stable marker of the deterministic empty-turn fallback text in
/// `turn_loop::PLANNING_COMPLETED_FALLBACK_RESPONSE`. Matched by substring so
/// the gate stays pure (no cross-module constant import) and robust to
/// surrounding file-list appends.
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)
}

/// Recoverable planning blocked-reason classifier.
///
/// Allow-list is evaluated **first**. Production
/// `PLANNING_COMPLETED_TURN_FALLBACK_REASON` ("Planning turn ended via
/// recovery fallback … approval-ready plan …") must auto-queue; a deny-first
/// match on "planning turn ended" / "approval-ready plan" incorrectly blocked
/// that path and forced a user `continue` nudge. Interview/approval/permission
/// handoffs stay denied after the allow-list misses.
pub(crate) fn plan_mode_recoverable_block(reason: &str) -> bool {
    // Refusals never auto-continue; see `tracker_auto_continue_is_recoverable_block`.
    if refusal::is_refusal_notice(reason) {
        return false;
    }
    let lower = reason.to_ascii_lowercase();
    // Budget-only verification blocks can retry on a fresh turn, same as the
    // tracker gate. Other verification blocks stay terminal.
    if is_budget_exhausted_verification_block(&lower) {
        return !verification_block_has_hard_deny(&lower, &["awaiting"]);
    }
    // True handoffs deny even when recovery/budget tokens are also present
    // (compound reasons must not auto-queue past a permission/interview wait).
    // "awaiting" is deliberately broader than the tracker's "awaiting approval".
    if contains_any_token(&lower, RECOVERABLE_BLOCK_BASE_DENY_TOKENS) || lower.contains("awaiting") {
        return false;
    }
    // Production recovery constants (including PLANNING_COMPLETED_TURN_FALLBACK_REASON)
    // are recoverable. Deny tokens like "planning turn ended" / "approval-ready
    // plan" must not shadow "recovery fallback".
    //
    // Entry-turn Blocked shapes common after mid-turn `start_planning`: the
    // model hits the read-only gate (blocked-tool fuse) or ends tools without a
    // published final. Both must auto-continue planning research instead of
    // parking at the user `Continue…` prompt.
    if matches_recoverable_block_shape(&lower) {
        return true;
    }
    // Remaining planning handoffs (interview/approval without recovery tokens).
    if lower.contains("planning turn ended") || lower.contains("approval-ready plan") {
        return false;
    }
    false
}

/// User-facing plan progress line (title + phase/status only).
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(),
    }
}

/// Stable opening marker for the harness-generated plan-mode auto-continue
/// directive. The planning exit trigger treats any user message carrying this
/// marker as machine-generated (not a genuine user turn), so the two sites
/// must share one literal instead of drifting.
pub(crate) const PLAN_MODE_AUTO_CONTINUE_MARKER: &str = "Plan-mode auto-continue:";

/// Shared tail of every plan-mode continuation message. States the
/// consequence (planning stays read-only, the harness resumed the turn, code
/// changes wait for approval) and the next step. It deliberately contains the
/// stay phrase `continue planning` and no implementation cue, so even without
/// the [`PLAN_MODE_AUTO_CONTINUE_MARKER`] guard it could never read as an
/// exit-and-implement intent.
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.";

/// Follow-up prompt for plan-mode auto-continue turns.
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}"
    )
}

/// System directive paired with an in-session plan-mode auto-continue.
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}"
    )
}

/// System directive paired with a plan-mode continuation queued on session
/// resume after a recoverable blocked handoff.
pub(crate) fn plan_mode_resume_directive() -> String {
    format!(
        "Resume continuation: planning remains active after a recoverable blocked handoff. \
{PLAN_MODE_CONTINUE_DIRECTIVE_TAIL}"
    )
}