vv-agent 0.11.0

VectorVein agent runtime, SDK, CLI, tools, and workspace backends
Documentation
use serde::{Deserialize, Serialize};
use serde_json::Value;

use super::{Message, Metadata, ToolArguments, ToolDirective, ToolResultStatus};

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToolCall {
    pub id: String,
    pub name: String,
    pub arguments: ToolArguments,
    pub extra_content: Option<Value>,
}

impl ToolCall {
    pub fn new(id: impl Into<String>, name: impl Into<String>, arguments: ToolArguments) -> Self {
        Self {
            id: id.into(),
            name: name.into(),
            arguments,
            extra_content: None,
        }
    }

    pub fn from_raw_arguments(
        id: impl Into<String>,
        name: impl Into<String>,
        raw_arguments: Value,
    ) -> Self {
        let id = id.into();
        let name = name.into();
        match parse_raw_tool_arguments(&raw_arguments) {
            Ok(arguments) => Self {
                id,
                name,
                arguments,
                extra_content: None,
            },
            Err((error_code, error)) => Self {
                id,
                name,
                arguments: ToolArguments::new(),
                extra_content: Some(Value::Object(
                    [
                        ("raw_arguments".to_string(), raw_arguments),
                        ("argument_error_code".to_string(), Value::String(error_code)),
                        ("argument_error".to_string(), Value::String(error)),
                    ]
                    .into_iter()
                    .collect(),
                )),
            },
        }
    }
}

fn parse_raw_tool_arguments(raw_arguments: &Value) -> Result<ToolArguments, (String, String)> {
    match raw_arguments {
        Value::Null => Ok(ToolArguments::new()),
        Value::Object(object) => Ok(object.clone().into_iter().collect()),
        Value::String(raw) => {
            let stripped = raw.trim();
            if stripped.is_empty() {
                return Ok(ToolArguments::new());
            }
            let parsed = serde_json::from_str::<Value>(stripped).map_err(|error| {
                (
                    "invalid_arguments_json".to_string(),
                    format!("Invalid tool arguments JSON: {error}"),
                )
            })?;
            match parsed {
                Value::Object(object) => Ok(object.into_iter().collect()),
                _ => Err((
                    "invalid_arguments_payload".to_string(),
                    "Tool arguments must decode to an object".to_string(),
                )),
            }
        }
        other => Err((
            "invalid_arguments_type".to_string(),
            format!("Unsupported tool argument type: {}", json_type_name(other)),
        )),
    }
}

fn json_type_name(value: &Value) -> &'static str {
    match value {
        Value::Null => "null",
        Value::Bool(_) => "bool",
        Value::Number(_) => "number",
        Value::String(_) => "string",
        Value::Array(_) => "array",
        Value::Object(_) => "object",
    }
}

#[derive(Debug, Clone, PartialEq)]
pub struct ToolExecutionResult {
    pub tool_call_id: String,
    pub content: String,
    pub status: ToolResultStatus,
    pub directive: ToolDirective,
    pub error_code: Option<String>,
    pub metadata: Metadata,
    pub image_url: Option<String>,
    pub image_path: Option<String>,
    pub truncated: bool,
    pub truncation_reason: Option<ToolTruncationReason>,
    pub original_bytes: Option<u64>,
    pub visible_bytes: Option<u64>,
    pub artifact: Option<ToolArtifactRef>,
    pub cursor: Option<ToolResultCursor>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolTruncationReason {
    OutputLimit,
    ReadLimit,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ToolArtifactRef {
    pub path: String,
    pub media_type: String,
    pub encoding: String,
    pub size_bytes: u64,
    pub sha256: String,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ToolResultCursor {
    pub kind: String,
    pub path: String,
    pub offset_chars: u64,
    pub sha256: String,
}

impl Serialize for ToolExecutionResult {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: serde::Serializer,
    {
        self.validate().map_err(serde::ser::Error::custom)?;
        self.to_dict().serialize(serializer)
    }
}

impl<'de> Deserialize<'de> for ToolExecutionResult {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        let value = Value::deserialize(deserializer)?;
        Self::from_dict(&value).map_err(serde::de::Error::custom)
    }
}

impl ToolExecutionResult {
    pub fn success(tool_call_id: impl Into<String>, content: impl Into<String>) -> Self {
        Self {
            tool_call_id: tool_call_id.into(),
            content: content.into(),
            status: ToolResultStatus::Success,
            directive: ToolDirective::Continue,
            error_code: None,
            metadata: Metadata::new(),
            image_url: None,
            image_path: None,
            truncated: false,
            truncation_reason: None,
            original_bytes: None,
            visible_bytes: None,
            artifact: None,
            cursor: None,
        }
    }

    pub fn error(tool_call_id: impl Into<String>, content: impl Into<String>) -> Self {
        Self {
            status: ToolResultStatus::Error,
            ..Self::success(tool_call_id, content)
        }
    }

    pub fn to_message(&self) -> Message {
        let mut content = self.content.clone();
        if self.truncated {
            let recovery = self.recovery_value();
            let encoded = serde_json_canonicalizer::to_string(&recovery)
                .expect("validated tool recovery fields are canonical JSON");
            content.push('\n');
            content.push_str(&encoded);
        }
        let mut message = Message::tool(content, self.tool_call_id.clone());
        message.artifact_ref = self.artifact.clone();
        if self.artifact.is_some() {
            message.metadata.insert(
                "_vv_agent_microcompact_excerpt".to_string(),
                Value::String(self.content.clone()),
            );
        }
        message
    }

    pub fn to_tool_message(&self) -> Message {
        self.to_message()
    }

    pub fn validate(&self) -> Result<(), String> {
        for forbidden in ["content", "instructions", "output", "stderr", "stdout"] {
            if self.metadata.contains_key(forbidden) {
                return Err(format!(
                    "tool_result_invalid: metadata key {forbidden:?} may not repeat bulk output"
                ));
            }
        }
        if !self.truncated {
            if self.truncation_reason.is_some()
                || self.original_bytes.is_some()
                || self.visible_bytes.is_some()
                || self.artifact.is_some()
                || self.cursor.is_some()
            {
                return Err(
                    "tool_result_invalid: ordinary results cannot contain recovery fields"
                        .to_string(),
                );
            }
            return Ok(());
        }

        let reason = self.truncation_reason.ok_or_else(|| {
            "tool_result_invalid: truncated result requires truncation_reason".to_string()
        })?;
        let original_bytes = self.original_bytes.ok_or_else(|| {
            "tool_result_invalid: truncated result requires original_bytes".to_string()
        })?;
        let visible_bytes = self.visible_bytes.ok_or_else(|| {
            "tool_result_invalid: truncated result requires visible_bytes".to_string()
        })?;
        if visible_bytes != self.content.len() as u64 || visible_bytes > original_bytes {
            return Err(
                "tool_result_invalid: truncated result byte counts are invalid".to_string(),
            );
        }
        match reason {
            ToolTruncationReason::OutputLimit => {
                let artifact = self.artifact.as_ref().ok_or_else(|| {
                    "tool_result_invalid: output_limit requires artifact".to_string()
                })?;
                if self.cursor.is_some() {
                    return Err(
                        "tool_result_invalid: output_limit cannot contain cursor".to_string()
                    );
                }
                artifact.validate()?;
            }
            ToolTruncationReason::ReadLimit => {
                let cursor = self
                    .cursor
                    .as_ref()
                    .ok_or_else(|| "tool_result_invalid: read_limit requires cursor".to_string())?;
                if self.artifact.is_some() {
                    return Err(
                        "tool_result_invalid: read_limit cannot contain artifact".to_string()
                    );
                }
                cursor.validate()?;
            }
        }
        Ok(())
    }

    fn recovery_value(&self) -> Value {
        let mut recovery = serde_json::Map::new();
        recovery.insert("truncated".to_string(), Value::Bool(true));
        recovery.insert(
            "truncation_reason".to_string(),
            Value::String(
                match self.truncation_reason.expect("validated truncated result") {
                    ToolTruncationReason::OutputLimit => "output_limit",
                    ToolTruncationReason::ReadLimit => "read_limit",
                }
                .to_string(),
            ),
        );
        recovery.insert(
            "original_bytes".to_string(),
            Value::from(self.original_bytes.expect("validated truncated result")),
        );
        recovery.insert(
            "visible_bytes".to_string(),
            Value::from(self.visible_bytes.expect("validated truncated result")),
        );
        if let Some(artifact) = &self.artifact {
            recovery.insert(
                "artifact".to_string(),
                serde_json::to_value(artifact).expect("ToolArtifactRef is serializable"),
            );
        }
        if let Some(cursor) = &self.cursor {
            recovery.insert(
                "cursor".to_string(),
                serde_json::to_value(cursor).expect("ToolResultCursor is serializable"),
            );
        }
        Value::Object(serde_json::Map::from_iter([(
            "vv_agent_recovery".to_string(),
            Value::Object(recovery),
        )]))
    }
}

impl ToolArtifactRef {
    pub fn validate(&self) -> Result<(), String> {
        if !valid_artifact_path(&self.path) {
            return Err("artifact_path_invalid".to_string());
        }
        if self.media_type != "text/plain" || self.encoding != "utf-8" {
            return Err(
                "tool_result_invalid: artifact media type or encoding is invalid".to_string(),
            );
        }
        if !valid_sha256(&self.sha256) {
            return Err("tool_result_invalid: artifact sha256 is invalid".to_string());
        }
        Ok(())
    }
}

impl ToolResultCursor {
    pub fn validate(&self) -> Result<(), String> {
        if self.kind != "read_file"
            || self.path.trim().is_empty()
            || self.path.contains(['\\', '\0'])
            || self.offset_chars > crate::budget::MAX_WIRE_INTEGER
            || !valid_sha256(&self.sha256)
        {
            return Err("tool_result_invalid: read_file cursor is invalid".to_string());
        }
        Ok(())
    }
}

fn valid_artifact_path(path: &str) -> bool {
    const PREFIX: &str = ".vv-agent/artifacts/";
    if path.len() > 512 || !path.starts_with(PREFIX) || path.contains(['\\', '\0']) {
        return false;
    }
    path[PREFIX.len()..].split('/').all(|segment| {
        !segment.is_empty()
            && segment.len() <= 128
            && segment
                .bytes()
                .next()
                .is_some_and(|byte| byte.is_ascii_alphanumeric())
            && segment
                .bytes()
                .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
    })
}

fn valid_sha256(value: &str) -> bool {
    value.len() == 64
        && value
            .bytes()
            .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
}