vtcode 0.99.1

A Rust-based terminal coding agent with modular architecture supporting multiple LLM providers
use serde_json::{Value, json};
use vtcode_core::config::constants::defaults::{
    DEFAULT_MAX_CONSECUTIVE_BLOCKED_TOOL_CALLS_PER_TURN, DEFAULT_MAX_REPEATED_TOOL_CALLS,
    DEFAULT_MAX_SEQUENTIAL_SPOOL_CHUNK_READS_PER_TURN,
};
use vtcode_core::config::constants::tools as tool_names;
use vtcode_core::tools::registry::labels::tool_action_label;

use crate::agent::runloop::unified::turn::context::{
    TurnHandlerOutcome, TurnLoopResult, TurnProcessingContext,
};
use crate::agent::runloop::unified::turn::tool_outcomes::helpers::signature_key_for;

use super::looping::{
    low_signal_family_key, shell_run_signature, spool_chunk_read_path,
    task_tracker_create_signature,
};
use super::{ValidationResult, build_failure_error_content};

const SPOOL_CHUNK_GREP_PATTERN: &str = "warning|error|TODO";
const MAX_CONSECUTIVE_SAME_FILE_READ_FAMILY_CALLS: usize = 4;

pub(crate) fn max_consecutive_blocked_tool_calls_per_turn(
    ctx: &TurnProcessingContext<'_>,
) -> usize {
    ctx.vt_cfg
        .map(|cfg| cfg.tools.max_consecutive_blocked_tool_calls_per_turn)
        .filter(|value| *value > 0)
        .unwrap_or(DEFAULT_MAX_CONSECUTIVE_BLOCKED_TOOL_CALLS_PER_TURN)
}

pub(super) fn enforce_blocked_tool_call_guard(
    ctx: &mut TurnProcessingContext<'_>,
    tool_call_id: &str,
    tool_name: &str,
    args: &Value,
) -> Option<TurnHandlerOutcome> {
    let streak = ctx.record_blocked_tool_call();
    let blocked_total = ctx.blocked_tool_calls();
    let max_streak = max_consecutive_blocked_tool_calls_per_turn(ctx);

    if ctx.is_recovery_active() && !ctx.recovery_pass_used() {
        return Some(TurnHandlerOutcome::Continue);
    }

    let recovery_total_fuse_tripped = ctx.is_recovery_active() && blocked_total > max_streak;
    if streak <= max_streak && !recovery_total_fuse_tripped {
        return None;
    }

    let display_tool = tool_action_label(tool_name, args);
    let block_reason = if recovery_total_fuse_tripped {
        format!(
            "Blocked tool calls reached the recovery-mode cap ({max_streak}) for this turn. Last blocked call: '{display_tool}'. Stopping turn."
        )
    } else {
        format!(
            "Consecutive blocked tool calls reached per-turn cap ({max_streak}). Last blocked call: '{display_tool}'. Stopping turn to prevent retry churn."
        )
    };
    ctx.push_tool_response(
        tool_call_id,
        build_failure_error_content(
            if recovery_total_fuse_tripped {
                format!("Blocked tool calls exceeded the recovery-mode cap ({max_streak}) for this turn.")
            } else {
                format!("Consecutive blocked tool calls exceeded cap ({max_streak}) for this turn.")
            },
            if recovery_total_fuse_tripped {
                "blocked_total"
            } else {
                "blocked_streak"
            },
        ),
    );
    ctx.push_system_message(block_reason.clone());

    Some(TurnHandlerOutcome::Break(TurnLoopResult::Blocked {
        reason: Some(block_reason),
    }))
}

fn max_consecutive_identical_shell_command_runs_per_turn(ctx: &TurnProcessingContext<'_>) -> usize {
    ctx.vt_cfg
        .map(|cfg| cfg.tools.max_repeated_tool_calls)
        .filter(|value| *value > 0)
        .unwrap_or(DEFAULT_MAX_REPEATED_TOOL_CALLS)
}

fn build_repeated_shell_run_error_content(max_repeated_runs: usize) -> String {
    super::super::execution_result::build_error_content(
        format!(
            "Repeated identical shell command runs exceeded per-turn cap ({}). Reuse prior output or change command before retrying.",
            max_repeated_runs
        ),
        None,
        None,
        "repeated_shell_run",
    )
    .to_string()
}

fn repeated_file_read_family_key(canonical_tool_name: &str, args: &Value) -> Option<String> {
    if spool_chunk_read_path(canonical_tool_name, args).is_some() {
        return None;
    }

    match canonical_tool_name {
        tool_names::READ_FILE | tool_names::UNIFIED_FILE => {
            low_signal_family_key(canonical_tool_name, args)
        }
        _ => None,
    }
}

fn build_repeated_file_read_family_error_content(target: &str) -> String {
    super::super::execution_result::build_error_content(
        format!(
            "Repeated reads of '{}' with limited progress detected. Reuse the collected output, summarize findings, or narrow the range before reading again.",
            target
        ),
        None,
        None,
        "repeated_read_family",
    )
    .to_string()
}

pub(super) fn enforce_duplicate_task_tracker_create_guard<'a>(
    ctx: &mut TurnProcessingContext<'a>,
    tool_call_id: &str,
    canonical_tool_name: &str,
    effective_args: &Value,
) -> Option<ValidationResult> {
    let signature = task_tracker_create_signature(canonical_tool_name, effective_args)?;

    if ctx
        .harness_state
        .record_task_tracker_create_signature(signature)
    {
        return None;
    }

    let content = super::super::execution_result::build_error_content(
        "Duplicate task_tracker.create detected in this turn. Use task_tracker.update/list to continue tracking progress."
            .to_string(),
        Some(tool_names::TASK_TRACKER.to_string()),
        Some(serde_json::json!({ "action": "list" })),
        "duplicate_task_tracker_create",
    )
    .to_string();
    ctx.push_tool_response(tool_call_id, content);
    Some(ValidationResult::Blocked)
}

pub(super) fn enforce_repeated_read_only_call_guard(
    ctx: &mut TurnProcessingContext<'_>,
    tool_call_id: &str,
    canonical_tool_name: &str,
    effective_args: &Value,
    readonly_classification: bool,
) -> Option<ValidationResult> {
    if !readonly_classification {
        return None;
    }

    if let Some(family_key) = repeated_file_read_family_key(canonical_tool_name, effective_args) {
        let streak = ctx
            .harness_state
            .record_file_read_family_call(family_key.clone());
        if streak >= MAX_CONSECUTIVE_SAME_FILE_READ_FAMILY_CALLS {
            let target = family_key.rsplit("::").next().unwrap_or("current file");
            let block_reason = format!(
                "Repeated read-only exploration of '{}' hit the per-turn family cap ({}). Scheduling a final recovery pass without more tools.",
                target, MAX_CONSECUTIVE_SAME_FILE_READ_FAMILY_CALLS
            );
            ctx.activate_recovery(block_reason.clone());
            ctx.push_tool_response(
                tool_call_id,
                build_repeated_file_read_family_error_content(target),
            );
            ctx.push_system_message(block_reason);
            return Some(ValidationResult::Blocked);
        }
    }

    let signature = signature_key_for(canonical_tool_name, effective_args);
    if !ctx
        .harness_state
        .has_successful_readonly_signature(signature.as_str())
    {
        return None;
    }

    let mut reused_value = ctx.tool_registry.find_recent_successful_output(
        canonical_tool_name,
        effective_args,
        ctx.harness_state.max_tool_wall_clock,
    )?;
    let obj = reused_value.as_object_mut()?;
    super::apply_reused_read_only_loop_metadata(obj);
    ctx.push_tool_response(tool_call_id, reused_value.to_string());

    Some(ValidationResult::Handled)
}

pub(super) fn enforce_repeated_shell_run_guard(
    ctx: &mut TurnProcessingContext<'_>,
    tool_call_id: &str,
    canonical_tool_name: &str,
    args: &Value,
) -> Option<ValidationResult> {
    let Some(signature) = shell_run_signature(canonical_tool_name, args) else {
        ctx.harness_state.reset_shell_command_run_streak();
        return None;
    };

    let max_repeated_runs = max_consecutive_identical_shell_command_runs_per_turn(ctx);
    let streak = ctx.harness_state.record_shell_command_run(signature);
    if streak <= max_repeated_runs {
        return None;
    }

    let display_tool = tool_action_label(canonical_tool_name, args);
    let block_reason = format!(
        "Repeated shell command guard stopped '{}' after {} identical runs (max {}). Scheduling a final recovery pass without more tools.",
        display_tool, streak, max_repeated_runs
    );
    ctx.activate_recovery(block_reason.clone());
    ctx.push_tool_response(
        tool_call_id,
        build_repeated_shell_run_error_content(max_repeated_runs),
    );
    ctx.push_system_message(block_reason);

    Some(ValidationResult::Blocked)
}

fn max_sequential_spool_chunk_reads_per_turn(ctx: &TurnProcessingContext<'_>) -> usize {
    ctx.vt_cfg
        .map(|cfg| cfg.tools.max_sequential_spool_chunk_reads)
        .filter(|value| *value > 0)
        .unwrap_or(DEFAULT_MAX_SEQUENTIAL_SPOOL_CHUNK_READS_PER_TURN)
}

fn spool_chunk_guard_fallback_args(path: &str) -> Value {
    json!({
        "action": "grep",
        "path": path,
        "pattern": SPOOL_CHUNK_GREP_PATTERN
    })
}

fn build_spool_chunk_guard_error_content(path: &str, max_reads_per_turn: usize) -> String {
    super::super::execution_result::build_error_content(
        format!(
            "Spool chunk reads exceeded per-turn cap ({}). Use targeted extraction before reading more from '{}'.",
            max_reads_per_turn, path
        ),
        Some(tool_names::UNIFIED_SEARCH.to_string()),
        Some(spool_chunk_guard_fallback_args(path)),
        "spool_chunk_guard",
    )
    .to_string()
}

pub(super) fn enforce_spool_chunk_read_guard(
    ctx: &mut TurnProcessingContext<'_>,
    tool_call_id: &str,
    canonical_tool_name: &str,
    args: &Value,
) -> Option<ValidationResult> {
    let Some(spool_path) = spool_chunk_read_path(canonical_tool_name, args) else {
        ctx.harness_state.reset_spool_chunk_read_streak();
        return None;
    };

    let max_reads_per_turn = max_sequential_spool_chunk_reads_per_turn(ctx);
    let streak = ctx.harness_state.record_spool_chunk_read();
    if streak <= max_reads_per_turn {
        return None;
    }

    let display_tool = tool_action_label(canonical_tool_name, args);
    let block_reason = format!(
        "Spool chunk guard stopped repeated '{}' calls for this turn. Scheduling a final recovery pass without more tools.",
        display_tool
    );

    ctx.activate_recovery(block_reason.clone());
    ctx.push_tool_response(
        tool_call_id,
        build_spool_chunk_guard_error_content(spool_path, max_reads_per_turn),
    );
    ctx.push_system_message(block_reason);

    Some(ValidationResult::Blocked)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn spool_chunk_guard_error_uses_unified_search_fallback() {
        let payload =
            build_spool_chunk_guard_error_content(".vtcode/context/tool_outputs/run-1.txt", 3);
        let parsed: Value =
            serde_json::from_str(&payload).expect("spool chunk guard payload should be json");

        assert_eq!(
            parsed.get("fallback_tool").and_then(Value::as_str),
            Some(tool_names::UNIFIED_SEARCH)
        );
        assert_eq!(parsed["fallback_tool_args"]["action"], "grep");
        assert_eq!(
            parsed["fallback_tool_args"]["path"],
            ".vtcode/context/tool_outputs/run-1.txt"
        );
        assert_eq!(
            parsed["fallback_tool_args"]["pattern"],
            SPOOL_CHUNK_GREP_PATTERN
        );
        assert!(parsed.get("next_action").and_then(Value::as_str).is_some());
    }
}