vv-agent 0.6.0

VectorVein agent runtime, SDK, CLI, tools, and workspace backends
Documentation
use std::sync::Arc;

use serde_json::json;
use serde_json::Value;
use sha2::{Digest, Sha256};

use crate::tools::base::{ToolContext, ToolSpec};
use crate::tools::common::{
    coerce_truthy_arg, path_escapes_workspace_error, replace_n, stringify_tool_arg,
};
use crate::types::{Metadata, ToolArguments, ToolExecutionResult, ToolResultStatus};

use super::workspace_backend_error;

const FILE_BASELINES_STATE_KEY: &str = "_workspace_file_baselines";
const MAX_DIFF_CHARS: usize = 8_000;

pub fn edit_file(context: &mut ToolContext, arguments: &ToolArguments) -> ToolExecutionResult {
    let spec = edit_file_tool();
    (spec.handler)(context, arguments)
}

pub(crate) fn edit_file_tool() -> ToolSpec {
    let mut spec = ToolSpec::new(
        "edit_file",
        "Safely edit an existing workspace file.",
        Arc::new(|context, arguments| {
            if !arguments.contains_key("path")
                || !arguments.contains_key("old_string")
                || !arguments.contains_key("new_string")
            {
                let path = stringify_tool_arg(arguments.get("path"), "");
                return workspace_tool_error(
                    "`path`, `old_string`, and `new_string` are required.",
                    "invalid_arguments",
                    &path,
                );
            }
            let path = stringify_tool_arg(arguments.get("path"), "");
            if let Err(error) = context.resolve_workspace_path(&path) {
                return path_escapes_workspace_error(error);
            }
            let backend = context.effective_workspace_backend();
            match backend.file_info(&path) {
                Ok(Some(info)) if info.is_file => {}
                Ok(_) => {
                    return workspace_tool_error(
                        format!("file not found: {path}"),
                        "file_not_found",
                        &path,
                    )
                }
                Err(error) => return workspace_backend_error(error),
            }
            let old_string = stringify_tool_arg(arguments.get("old_string"), "");
            if old_string.is_empty() {
                return workspace_tool_error(
                    "`old_string` cannot be empty.",
                    "old_string_empty",
                    &path,
                );
            }
            let new_string = stringify_tool_arg(arguments.get("new_string"), "");
            if old_string == new_string {
                return workspace_tool_error(
                    "No changes: old_string and new_string are identical.",
                    "no_changes",
                    &path,
                );
            }
            let replace_all = coerce_truthy_arg(arguments.get("replace_all"), false);

            match backend.read_bytes(&path) {
                Ok(raw) => {
                    let text = match String::from_utf8(raw.clone()) {
                        Ok(text) => text,
                        Err(_) => {
                            return workspace_tool_error(
                                "Unsupported file encoding for edit_file.",
                                "unsupported_encoding",
                                &path,
                            )
                        }
                    };
                    if let Some(issue) = baseline_issue(context, &path, &raw) {
                        let message = if issue == "file_changed_since_read" {
                            "File changed since it was last read. Re-read it before editing."
                        } else {
                            "Read the full file with read_file before editing."
                        };
                        return workspace_tool_error(message, issue, &path);
                    }

                    let line_ending = detect_line_ending(&text);
                    let mut actual_old = old_string.clone();
                    let mut actual_new = new_string.clone();
                    let mut occurrence_count = text.matches(&actual_old).count();
                    if occurrence_count == 0
                        && line_ending == "crlf"
                        && !old_string.contains("\r\n")
                    {
                        actual_old = old_string.replace('\n', "\r\n");
                        actual_new = new_string.replace('\n', "\r\n");
                        occurrence_count = text.matches(&actual_old).count();
                    }
                    if occurrence_count == 0 {
                        return workspace_tool_error(
                            "`old_string` not found in file.",
                            "old_string_not_found",
                            &path,
                        );
                    }
                    if occurrence_count > 1 && !replace_all {
                        return workspace_tool_error(
                            format!(
                                "`old_string` matched {occurrence_count} locations; make it unique or set replace_all=true."
                            ),
                            "old_string_not_unique",
                            &path,
                        );
                    }
                    let replaced_count = if replace_all { occurrence_count } else { 1 };
                    let replaced_text = if replace_all {
                        text.replace(&actual_old, &actual_new)
                    } else {
                        replace_n(&text, &actual_old, &actual_new, 1)
                    };
                    match backend.write_text(&path, &replaced_text, false) {
                        Ok(_) => {
                            record_file_baseline(context, &path, replaced_text.as_bytes(), false);
                            edit_success_result(
                                &path,
                                &text,
                                &replaced_text,
                                replaced_count,
                                line_ending,
                            )
                        }
                        Err(error) => workspace_backend_error(error),
                    }
                }
                Err(error) => workspace_backend_error(error),
            }
        }),
    );
    if let Some(schema) = crate::tools::schemas::schema_for("edit_file") {
        spec.schema = schema;
    }
    spec
}

pub(crate) fn workspace_tool_error(
    message: impl Into<String>,
    error_code: impl Into<String>,
    path: &str,
) -> ToolExecutionResult {
    let message = message.into();
    let error_code = error_code.into();
    ToolExecutionResult {
        tool_call_id: String::new(),
        content: json!({
            "ok": false,
            "error": message,
            "message": message,
            "error_code": error_code,
            "path": path,
        })
        .to_string(),
        status: ToolResultStatus::Error,
        directive: crate::types::ToolDirective::Continue,
        error_code: Some(error_code),
        metadata: Metadata::new(),
        image_url: None,
        image_path: None,
    }
}

pub(crate) fn record_file_baseline(
    context: &mut ToolContext,
    path: &str,
    raw: &[u8],
    is_partial: bool,
) {
    let line_ending = String::from_utf8(raw.to_vec())
        .map(|text| detect_line_ending(&text).to_string())
        .unwrap_or_else(|_| "unknown".to_string());
    let entry = json!({
        "hash": content_hash(raw),
        "size": raw.len(),
        "line_ending": line_ending,
        "is_partial": is_partial,
    });
    let baselines = context
        .shared_state
        .entry(FILE_BASELINES_STATE_KEY.to_string())
        .or_insert_with(|| Value::Object(Default::default()));
    if !baselines.is_object() {
        *baselines = Value::Object(Default::default());
    }
    baselines
        .as_object_mut()
        .expect("baselines object")
        .insert(path.to_string(), entry);
}

pub(crate) fn baseline_issue(
    context: &ToolContext,
    path: &str,
    current_raw: &[u8],
) -> Option<&'static str> {
    let Some(baseline) = context
        .shared_state
        .get(FILE_BASELINES_STATE_KEY)
        .and_then(Value::as_object)
        .and_then(|object| object.get(path))
        .and_then(Value::as_object)
    else {
        return Some("file_not_read");
    };
    if baseline
        .get("is_partial")
        .and_then(Value::as_bool)
        .unwrap_or(true)
    {
        return Some("file_not_read");
    }
    let baseline_hash = baseline.get("hash").and_then(Value::as_str);
    if baseline_hash != Some(content_hash(current_raw).as_str()) {
        return Some("file_changed_since_read");
    }
    None
}

pub(crate) fn changed_file_metadata(
    path: &str,
    before: &str,
    after: &str,
    operation: &str,
    line_ending: &str,
) -> Metadata {
    let (diff, diff_truncated, additions, deletions) = bounded_diff(path, before, after);
    let mut metadata = Metadata::new();
    metadata.insert("changed_files".to_string(), json!([path]));
    metadata.insert("diff".to_string(), Value::String(diff));
    metadata.insert("additions".to_string(), json!(additions));
    metadata.insert("deletions".to_string(), json!(deletions));
    metadata.insert(
        "operation".to_string(),
        Value::String(operation.to_string()),
    );
    metadata.insert(
        "line_ending".to_string(),
        Value::String(line_ending.to_string()),
    );
    if diff_truncated {
        metadata.insert("diff_truncated".to_string(), Value::Bool(true));
    }
    metadata
}

pub(crate) fn detect_line_ending(text: &str) -> &'static str {
    let lf_count = text.matches('\n').count();
    let crlf_count = text.matches("\r\n").count();
    if crlf_count > 0 && crlf_count == lf_count {
        "crlf"
    } else if crlf_count > 0 {
        "mixed"
    } else {
        "lf"
    }
}

fn edit_success_result(
    path: &str,
    before: &str,
    after: &str,
    replaced_count: usize,
    line_ending: &str,
) -> ToolExecutionResult {
    ToolExecutionResult {
        tool_call_id: String::new(),
        content: json!({
            "ok": true,
            "path": path,
            "replaced_count": replaced_count,
        })
        .to_string(),
        status: ToolResultStatus::Success,
        directive: crate::types::ToolDirective::Continue,
        error_code: None,
        metadata: changed_file_metadata(path, before, after, "edit_file", line_ending),
        image_url: None,
        image_path: None,
    }
}

fn content_hash(raw: &[u8]) -> String {
    let digest = Sha256::digest(raw);
    format!("{digest:x}")
}

fn bounded_diff(path: &str, before: &str, after: &str) -> (String, bool, usize, usize) {
    let before_lines = before.lines().collect::<Vec<_>>();
    let after_lines = after.lines().collect::<Vec<_>>();
    let mut prefix = 0;
    while prefix < before_lines.len()
        && prefix < after_lines.len()
        && before_lines[prefix] == after_lines[prefix]
    {
        prefix += 1;
    }
    let mut suffix = 0;
    while suffix + prefix < before_lines.len()
        && suffix + prefix < after_lines.len()
        && before_lines[before_lines.len() - 1 - suffix]
            == after_lines[after_lines.len() - 1 - suffix]
    {
        suffix += 1;
    }
    let before_changed = &before_lines[prefix..before_lines.len().saturating_sub(suffix)];
    let after_changed = &after_lines[prefix..after_lines.len().saturating_sub(suffix)];
    let mut diff = format!("--- {path}\n+++ {path}\n@@\n");
    for line in before_changed {
        diff.push('-');
        diff.push_str(line);
        diff.push('\n');
    }
    for line in after_changed {
        diff.push('+');
        diff.push_str(line);
        diff.push('\n');
    }
    let truncated = diff.len() > MAX_DIFF_CHARS;
    if truncated {
        diff.truncate(MAX_DIFF_CHARS);
    }
    (diff, truncated, after_changed.len(), before_changed.len())
}