vv-agent 0.11.0

VectorVein agent runtime, SDK, CLI, tools, and workspace backends
Documentation
use std::time::Duration;

use serde_json::{json, Value};

use crate::runtime::background_sessions::{
    background_session_manager, BackgroundSessionAdoptOptions,
};
use crate::runtime::processes::{
    read_captured_output, remove_captured_output, start_captured_process_with_env, wait_for_child,
};
use crate::runtime::shell::prepare_shell_execution;
use crate::tools::base::ToolContext;
use crate::tools::common::{
    bool_arg, integer_arg, path_escapes_workspace_error, string_arg, tool_error_with_code,
    tool_result_with_metadata, workspace_relative_path_or_absolute,
};
use crate::types::{Metadata, ToolArguments, ToolDirective, ToolExecutionResult, ToolResultStatus};
use crate::workspace::{
    artifact_write_error_code, bounded_captured_text_preview, bounded_text_preview,
    persist_captured_text_artifact,
};

use super::env::build_process_env;
use super::shell_defaults::read_shell_defaults;

pub(super) fn execute_bash_command(
    context: &mut ToolContext,
    arguments: &ToolArguments,
) -> ToolExecutionResult {
    let command = string_arg(arguments.get("command"), "").trim().to_string();
    if command.is_empty() {
        return tool_error_with_code("`command` is required", "command_required");
    }
    if let Some(snippet) = blocked_dangerous_snippet(&command) {
        return tool_error_with_code(
            format!("dangerous command blocked: {snippet}"),
            "dangerous_command",
        );
    }

    let exec_dir = string_arg(arguments.get("exec_dir"), ".");
    let cwd = match context.resolve_workspace_path(&exec_dir) {
        Ok(cwd) => cwd,
        Err(error) => return path_escapes_workspace_error(error),
    };
    if !cwd.is_dir() {
        return tool_error_with_code(
            format!("exec_dir not found: {exec_dir}"),
            "invalid_exec_dir",
        );
    }

    let timeout_seconds = match read_timeout_seconds(arguments) {
        Ok(timeout_seconds) => timeout_seconds,
        Err(error) => return tool_error_with_code(error, "invalid_timeout"),
    };
    let stdin_text = arguments
        .contains_key("stdin")
        .then(|| string_arg(arguments.get("stdin"), ""));
    let auto_confirm = bool_arg(arguments.get("auto_confirm"), false);
    let run_in_background = bool_arg(arguments.get("run_in_background"), false);
    let (shell, windows_shell_priority, bash_env) = match read_shell_defaults(&context.metadata) {
        Ok(defaults) => defaults,
        Err(error) => return tool_error_with_code(error, "invalid_shell_config"),
    };
    let configured_shell = shell.clone();
    let process_env = build_process_env(bash_env.as_ref());
    let prepared = match prepare_shell_execution(
        &command,
        auto_confirm,
        stdin_text.as_deref(),
        shell.as_deref(),
        windows_shell_priority.as_deref(),
    ) {
        Ok(prepared) => prepared,
        Err(error) => return tool_error_with_code(error, "invalid_shell_config"),
    };
    let started = start_captured_process_with_env(
        &prepared.command,
        &cwd,
        prepared.stdin.as_deref(),
        process_env.as_ref(),
    );
    let mut started = match started {
        Ok(started) => started,
        Err(error) => {
            let shell = configured_shell
                .as_deref()
                .or(prepared.shell.as_deref())
                .unwrap_or("shell");
            return tool_error_with_code(
                format!("Failed to start {shell}: {error}"),
                "command_failed",
            );
        }
    };

    if run_in_background {
        let session_id = adopt_background_process(
            context,
            command,
            cwd,
            timeout_seconds,
            started.child,
            started.output_path,
            configured_shell.clone(),
        );
        let mut payload = json!({
            "status": "running",
            "session_id": session_id,
        });
        if let Some(shell) = configured_shell {
            payload["shell"] = Value::String(shell);
        }
        let metadata = selected_metadata(&payload, &["status", "session_id", "shell"]);
        return tool_result_with_metadata(
            ToolResultStatus::Running,
            payload,
            None,
            ToolDirective::Continue,
            metadata,
        );
    }

    match wait_for_child(&mut started.child, Duration::from_secs(timeout_seconds)) {
        Ok(Some(exit_status)) => {
            let exit_code = exit_status.code().unwrap_or(-1);
            let terminal = terminal_output_result(
                context,
                &cwd,
                configured_shell,
                exit_code,
                &started.output_path,
            );
            if terminal.remove_captured_output {
                remove_captured_output(&started.output_path);
            }
            terminal.result
        }
        Ok(None) => {
            let output = read_captured_output(&started.output_path, 12_000);
            let session_id = adopt_background_process(
                context,
                command,
                cwd,
                timeout_seconds,
                started.child,
                started.output_path,
                configured_shell.clone(),
            );
            let mut payload = json!({
                "status": "running",
                "session_id": session_id,
                "cwd": exec_dir,
                "message": format!(
                    "command exceeded foreground timeout after {timeout_seconds} seconds and continues in background; use `check_background_command` with this session_id to inspect progress"
                ),
                "output": output,
                "transitioned_to_background": true,
            });
            if let Some(shell) = configured_shell {
                payload["shell"] = Value::String(shell);
            }
            let metadata = selected_metadata(
                &payload,
                &[
                    "status",
                    "session_id",
                    "cwd",
                    "shell",
                    "transitioned_to_background",
                ],
            );
            tool_result_with_metadata(
                ToolResultStatus::Running,
                payload,
                None,
                ToolDirective::Continue,
                metadata,
            )
        }
        Err(error) => tool_error_with_code(error.to_string(), "command_failed"),
    }
}

fn adopt_background_process(
    context: &ToolContext,
    command: String,
    cwd: std::path::PathBuf,
    timeout_seconds: u64,
    child: std::process::Child,
    output_path: std::path::PathBuf,
    shell: Option<String>,
) -> String {
    let mut options =
        BackgroundSessionAdoptOptions::new(command, cwd, timeout_seconds, child, output_path)
            .with_artifact_context(
                context.effective_workspace_backend(),
                context.task_id.clone(),
                context.tool_call_id.clone(),
            );
    options.shell = shell;
    background_session_manager().adopt_running_process_with_options(options)
}

struct TerminalOutputResult {
    result: ToolExecutionResult,
    remove_captured_output: bool,
}

fn terminal_output_result(
    context: &ToolContext,
    cwd: &std::path::Path,
    shell: Option<String>,
    exit_code: i32,
    output_path: &std::path::Path,
) -> TerminalOutputResult {
    let mut preview = match bounded_captured_text_preview(output_path) {
        Ok(preview) => preview,
        Err(error) => {
            return TerminalOutputResult {
                result: tool_error_with_code(
                    format!("failed to read command output: {error}"),
                    "command_failed",
                ),
                remove_captured_output: false,
            }
        }
    };
    if preview.content.is_empty() && exit_code != 0 {
        preview = bounded_text_preview(&format!("command exited with code {exit_code}"));
    }
    let artifact = if preview.truncated {
        match persist_captured_text_artifact(
            context.effective_workspace_backend(),
            &context.task_id,
            &context.tool_call_id,
            output_path,
        ) {
            Ok(artifact) => Some(artifact),
            Err(error) => {
                let error_code = artifact_write_error_code(&error);
                return TerminalOutputResult {
                    result: tool_error_with_code(
                        format!("failed to persist complete command output: {error}"),
                        error_code,
                    ),
                    remove_captured_output: false,
                };
            }
        }
    } else {
        None
    };
    let mut metadata = Metadata::from([
        (
            "cwd".to_string(),
            Value::String(workspace_relative_path_or_absolute(&context.workspace, cwd)),
        ),
        ("exit_code".to_string(), Value::from(exit_code)),
    ]);
    if let Some(shell) = shell {
        metadata.insert("shell".to_string(), Value::String(shell));
    }
    if exit_code != 0 {
        metadata.insert(
            "error_code".to_string(),
            Value::String("command_failed".to_string()),
        );
    }
    let mut result = ToolExecutionResult::success("", preview.content);
    result.status = if exit_code == 0 {
        ToolResultStatus::Success
    } else {
        ToolResultStatus::Error
    };
    result.error_code = (exit_code != 0).then(|| "command_failed".to_string());
    result.metadata = metadata;
    if let Some(artifact) = artifact {
        result.truncated = true;
        result.truncation_reason = Some(crate::types::ToolTruncationReason::OutputLimit);
        result.original_bytes = Some(preview.original_bytes);
        result.visible_bytes = Some(preview.visible_bytes);
        result.artifact = Some(artifact);
    }
    TerminalOutputResult {
        result,
        remove_captured_output: true,
    }
}

fn selected_metadata(payload: &Value, keys: &[&str]) -> Metadata {
    let Some(object) = payload.as_object() else {
        return Metadata::new();
    };
    keys.iter()
        .filter_map(|key| {
            let value = object.get(*key)?;
            (!value.is_null()).then(|| ((*key).to_string(), value.clone()))
        })
        .collect()
}

fn blocked_dangerous_snippet(command: &str) -> Option<&'static str> {
    let lowered = command.to_ascii_lowercase();
    [
        "rm -rf /",
        "shutdown",
        "reboot",
        "mkfs",
        "dd if=/dev/zero of=/dev/",
    ]
    .into_iter()
    .find(|snippet| lowered.contains(snippet))
}

fn read_timeout_seconds(arguments: &ToolArguments) -> Result<u64, String> {
    match arguments.get("timeout") {
        Some(value) => match integer_arg(value) {
            Ok(timeout) => Ok(timeout.clamp(1, 600) as u64),
            Err(_) => Err("`timeout` must be an integer".to_string()),
        },
        None => Ok(300),
    }
}