vtcode 0.99.1

A Rust-based terminal coding agent with modular architecture supporting multiple LLM providers
use anyhow::Result;
use vtcode_core::config::constants::tools as tool_names;
use vtcode_core::persistent_memory::GroundedFactRecord;

use crate::agent::runloop::unified::turn::compaction::{
    SessionMemoryEnvelopeUpdate, refresh_session_memory_envelope,
};
use crate::agent::runloop::unified::turn::context::TurnProcessingContext;

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(super) struct ParsedSubagentSummary {
    pub(super) summary: Vec<String>,
    pub(super) facts: Vec<String>,
    pub(super) touched_files: Vec<String>,
    pub(super) verification: Vec<String>,
    pub(super) open_questions: Vec<String>,
}

pub(super) fn request_user_input_result_stats(output: &serde_json::Value) -> (usize, bool) {
    let cancelled = output
        .get("cancelled")
        .and_then(serde_json::Value::as_bool)
        .unwrap_or(false);
    if cancelled {
        return (0, true);
    }

    let Some(answers) = output.get("answers").and_then(serde_json::Value::as_object) else {
        return (0, true);
    };

    let answered_questions = answers
        .values()
        .filter(|answer| {
            let selected_count = answer
                .get("selected")
                .and_then(serde_json::Value::as_array)
                .map_or(0, Vec::len);
            let has_other = answer
                .get("other")
                .and_then(serde_json::Value::as_str)
                .map(|text| !text.trim().is_empty())
                .unwrap_or(false);
            selected_count > 0 || has_other
        })
        .count();
    let cancelled = answered_questions == 0;
    (answered_questions, cancelled)
}

pub(super) fn record_request_user_input_interview_result(
    ctx: &mut TurnProcessingContext<'_>,
    tool_name: &str,
    output: Option<&serde_json::Value>,
) {
    if tool_name != tool_names::REQUEST_USER_INPUT {
        return;
    }

    let (answered_questions, cancelled) = output
        .map(request_user_input_result_stats)
        .unwrap_or((0, true));
    ctx.session_stats
        .record_plan_mode_interview_result(answered_questions, cancelled);
}

fn normalize_subagent_section_items(lines: &[String]) -> Vec<String> {
    lines
        .iter()
        .map(|line| line.trim())
        .filter(|line| !line.is_empty())
        .filter_map(|line| {
            let item = line
                .strip_prefix("- ")
                .or_else(|| line.strip_prefix("* "))
                .unwrap_or(line)
                .trim();
            (!item.eq_ignore_ascii_case("none")).then_some(item.to_string())
        })
        .collect()
}

pub(super) fn parse_subagent_summary_markdown(summary: &str) -> Option<ParsedSubagentSummary> {
    #[derive(Clone, Copy)]
    enum Section {
        Summary,
        Facts,
        TouchedFiles,
        Verification,
        OpenQuestions,
    }

    let mut current = None;
    let mut summary_lines = Vec::new();
    let mut fact_lines = Vec::new();
    let mut touched_files = Vec::new();
    let mut verification = Vec::new();
    let mut open_questions = Vec::new();
    let mut saw_contract = false;

    for raw_line in summary.replace("\r\n", "\n").lines() {
        let line = raw_line.trim();
        if line.is_empty() {
            continue;
        }

        current = match line {
            "## Summary" => {
                saw_contract = true;
                Some(Section::Summary)
            }
            "## Facts" => {
                saw_contract = true;
                Some(Section::Facts)
            }
            "## Touched Files" => {
                saw_contract = true;
                Some(Section::TouchedFiles)
            }
            "## Verification" => {
                saw_contract = true;
                Some(Section::Verification)
            }
            "## Open Questions" => {
                saw_contract = true;
                Some(Section::OpenQuestions)
            }
            _ => current,
        };

        if line.starts_with("## ") {
            continue;
        }

        match current {
            Some(Section::Summary) => summary_lines.push(line.to_string()),
            Some(Section::Facts) => fact_lines.push(line.to_string()),
            Some(Section::TouchedFiles) => touched_files.push(line.to_string()),
            Some(Section::Verification) => verification.push(line.to_string()),
            Some(Section::OpenQuestions) => open_questions.push(line.to_string()),
            None => {}
        }
    }

    saw_contract.then(|| ParsedSubagentSummary {
        summary: normalize_subagent_section_items(&summary_lines),
        facts: normalize_subagent_section_items(&fact_lines),
        touched_files: normalize_subagent_section_items(&touched_files),
        verification: normalize_subagent_section_items(&verification),
        open_questions: normalize_subagent_section_items(&open_questions),
    })
}

fn extract_completed_subagent_entries(output: &serde_json::Value) -> Vec<&serde_json::Value> {
    let mut entries = Vec::new();

    if output.get("completed").and_then(serde_json::Value::as_bool) == Some(true)
        && let Some(entry) = output.get("entry")
    {
        entries.push(entry);
    }

    if output
        .get("status")
        .and_then(serde_json::Value::as_str)
        .is_some_and(|status| status == "completed")
    {
        entries.push(output);
    }

    entries
}

pub(super) fn build_subagent_memory_update(
    output: &serde_json::Value,
) -> Option<SessionMemoryEnvelopeUpdate> {
    let entries = extract_completed_subagent_entries(output);
    if entries.is_empty() {
        return None;
    }

    let mut update = SessionMemoryEnvelopeUpdate::default();
    let mut saw_summary = false;
    for entry in entries {
        let Some(summary) = entry.get("summary").and_then(serde_json::Value::as_str) else {
            continue;
        };
        if summary.trim().is_empty() {
            continue;
        }

        let agent_name = entry
            .get("agent_name")
            .and_then(serde_json::Value::as_str)
            .unwrap_or("subagent");
        saw_summary = true;

        if let Some(parsed) = parse_subagent_summary_markdown(summary) {
            update
                .grounded_facts
                .extend(parsed.facts.into_iter().map(|fact| GroundedFactRecord {
                    fact,
                    source: format!("subagent:{agent_name}"),
                }));
            update.touched_files.extend(parsed.touched_files);
            update.open_questions.extend(parsed.open_questions);
            update.verification_todo.extend(parsed.verification);
            if !parsed.summary.is_empty() {
                update
                    .delegation_notes
                    .push(format!("{agent_name}: {}", parsed.summary.join(" | ")));
            }
        } else {
            update
                .delegation_notes
                .push(format!("{agent_name}: {}", summary.trim()));
        }
    }

    (saw_summary
        && (!update.grounded_facts.is_empty()
            || !update.touched_files.is_empty()
            || !update.open_questions.is_empty()
            || !update.verification_todo.is_empty()
            || !update.delegation_notes.is_empty()))
    .then_some(update)
}

pub(super) fn merge_subagent_completion_into_memory(
    ctx: &mut TurnProcessingContext<'_>,
    tool_name: &str,
    output: &serde_json::Value,
) -> Result<()> {
    if !matches!(
        tool_name,
        tool_names::SPAWN_AGENT
            | tool_names::SEND_INPUT
            | tool_names::WAIT_AGENT
            | tool_names::RESUME_AGENT
            | tool_names::CLOSE_AGENT
    ) {
        return Ok(());
    }

    let session_id = ctx.tool_registry.harness_context_snapshot().session_id;
    let Some(update) = build_subagent_memory_update(output) else {
        return Ok(());
    };

    if !update.touched_files.is_empty() {
        ctx.session_stats
            .record_touched_files(update.touched_files.iter().cloned());
    }

    refresh_session_memory_envelope(
        ctx.config.workspace.as_path(),
        &session_id,
        ctx.vt_cfg,
        ctx.working_history,
        ctx.session_stats,
        Some(&update),
    )?;

    Ok(())
}