magi-code 0.77.1

Repository-aware CLI coding agent for terminal work
Documentation
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

use crate::subagents::{DEFAULT_SUBAGENT_MAX_DEPTH, MAX_SUBAGENT_MAX_DEPTH};

use super::helpers::default_true;

#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct ToolOutputCompressionSettings {
    #[serde(default)]
    pub enabled: bool,
}

impl ToolOutputCompressionSettings {
    pub(crate) fn is_default(&self) -> bool {
        self == &Self::default()
    }
}

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ToolSettings {
    pub read: ReadToolSettings,
    pub view_image: ViewImageToolSettings,
    pub hash_edit: HashEditToolSettings,
    pub write: WriteToolSettings,
    pub grep: GrepToolSettings,
    pub find: FindToolSettings,
    pub list_files: ListFilesToolSettings,
    pub ast_grep: AstGrepToolSettings,
    pub bash: BashToolSettings,
    pub subagents: SubagentsToolSettings,
    pub output_compression: ToolOutputCompressionSettings,
    pub disabled: Vec<String>,
}

#[derive(Serialize, Deserialize, JsonSchema)]
struct ToolSettingsWire {
    #[serde(default, skip_serializing_if = "ReadToolSettings::is_default")]
    #[schemars(!skip_serializing_if)]
    read: ReadToolSettings,
    #[serde(default, skip_serializing_if = "ViewImageToolSettings::is_default")]
    #[schemars(!skip_serializing_if)]
    view_image: ViewImageToolSettings,
    #[serde(default, skip_serializing_if = "HashEditToolSettings::is_default")]
    #[schemars(!skip_serializing_if)]
    hash_edit: HashEditToolSettings,
    #[serde(default, skip_serializing_if = "WriteToolSettings::is_default")]
    #[schemars(!skip_serializing_if)]
    write: WriteToolSettings,
    #[serde(
        default,
        alias = "ffgrep",
        skip_serializing_if = "GrepToolSettings::is_default"
    )]
    #[schemars(!skip_serializing_if)]
    grep: GrepToolSettings,
    #[serde(
        default,
        alias = "fffind",
        skip_serializing_if = "FindToolSettings::is_default"
    )]
    #[schemars(!skip_serializing_if)]
    find: FindToolSettings,
    #[serde(default, skip_serializing_if = "ListFilesToolSettings::is_default")]
    #[schemars(!skip_serializing_if)]
    list_files: ListFilesToolSettings,
    #[serde(default, skip_serializing_if = "AstGrepToolSettings::is_default")]
    #[schemars(!skip_serializing_if)]
    ast_grep: AstGrepToolSettings,
    #[serde(default, skip_serializing_if = "BashToolSettings::is_default")]
    #[schemars(!skip_serializing_if)]
    bash: BashToolSettings,
    #[serde(
        default,
        alias = "parallel_subagents",
        skip_serializing_if = "SubagentsToolSettings::is_default"
    )]
    subagents: SubagentsToolSettings,
    #[serde(
        default,
        skip_serializing_if = "ToolOutputCompressionSettings::is_default"
    )]
    #[schemars(!skip_serializing_if)]
    output_compression: ToolOutputCompressionSettings,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    #[schemars(!skip_serializing_if)]
    disabled: Vec<String>,
}

impl From<ToolSettingsWire> for ToolSettings {
    fn from(wire: ToolSettingsWire) -> Self {
        Self {
            read: wire.read,
            view_image: wire.view_image,
            hash_edit: wire.hash_edit,
            write: wire.write,
            grep: wire.grep,
            find: wire.find,
            list_files: wire.list_files,
            ast_grep: wire.ast_grep,
            bash: wire.bash,
            subagents: wire.subagents,
            output_compression: wire.output_compression,
            disabled: wire.disabled,
        }
    }
}

impl From<&ToolSettings> for ToolSettingsWire {
    fn from(settings: &ToolSettings) -> Self {
        Self {
            read: settings.read.clone(),
            view_image: settings.view_image.clone(),
            hash_edit: settings.hash_edit.clone(),
            write: settings.write.clone(),
            grep: settings.grep.clone(),
            find: settings.find.clone(),
            list_files: settings.list_files.clone(),
            ast_grep: settings.ast_grep.clone(),
            bash: settings.bash.clone(),
            subagents: settings.subagents.clone(),
            output_compression: settings.output_compression.clone(),
            disabled: settings.disabled.to_vec(),
        }
    }
}

impl Serialize for ToolSettings {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: serde::Serializer,
    {
        ToolSettingsWire::from(self).serialize(serializer)
    }
}

impl<'de> Deserialize<'de> for ToolSettings {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        ToolSettingsWire::deserialize(deserializer).map(Into::into)
    }
}

impl JsonSchema for ToolSettings {
    fn schema_name() -> std::borrow::Cow<'static, str> {
        "ToolSettings".into()
    }

    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
        ToolSettingsWire::json_schema(generator)
    }
}

macro_rules! absolute_path_tool_settings {
    ($($name:ident),+ $(,)?) => {
        $(
            #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
            pub struct $name {
                #[serde(default = "default_true")]
                pub absolute_paths: bool,
            }

            impl Default for $name {
                fn default() -> Self {
                    Self {
                        absolute_paths: true,
                    }
                }
            }
            impl $name {
                pub(crate) fn is_default(&self) -> bool {
                    self == &Self::default()
                }
            }
        )+
    };
}

absolute_path_tool_settings!(
    ReadToolSettings,
    HashEditToolSettings,
    WriteToolSettings,
    GrepToolSettings,
    FindToolSettings,
    ListFilesToolSettings,
    AstGrepToolSettings,
);

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct SubagentsToolSettings {
    #[serde(default = "default_true")]
    pub absolute_paths: bool,
    #[serde(default = "default_subagent_max_depth")]
    pub max_depth: usize,
}

impl Default for SubagentsToolSettings {
    fn default() -> Self {
        Self {
            absolute_paths: true,
            max_depth: DEFAULT_SUBAGENT_MAX_DEPTH,
        }
    }
}

impl SubagentsToolSettings {
    pub(crate) fn is_default(&self) -> bool {
        self == &Self::default()
    }
}

fn default_subagent_max_depth() -> usize {
    DEFAULT_SUBAGENT_MAX_DEPTH
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct BashToolSettings {
    #[serde(default = "default_true")]
    pub absolute_paths: bool,
    #[serde(default = "default_true")]
    pub shell_expansion: bool,
}

impl Default for BashToolSettings {
    fn default() -> Self {
        Self {
            absolute_paths: true,
            shell_expansion: true,
        }
    }
}

impl BashToolSettings {
    pub(crate) fn is_default(&self) -> bool {
        self == &Self::default()
    }
}

pub const DEFAULT_VIEW_IMAGE_MAX_IMAGE_BYTES: u64 = 5 * 1024 * 1024;
pub const MAX_VIEW_IMAGE_MAX_IMAGE_BYTES: u64 = 20 * 1024 * 1024;

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct ViewImageToolSettings {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub vision_model: Option<ViewImageVisionModelSettings>,
    #[serde(default = "default_true")]
    pub absolute_paths: bool,
    #[serde(default = "default_view_image_max_image_bytes")]
    pub max_image_bytes: u64,
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct ViewImageVisionModelSettings {
    pub provider: String,
    pub model: String,
}

impl ViewImageToolSettings {
    pub(crate) fn is_default(&self) -> bool {
        self == &Self::default()
    }
}

impl Default for ViewImageToolSettings {
    fn default() -> Self {
        Self {
            vision_model: None,
            absolute_paths: true,
            max_image_bytes: DEFAULT_VIEW_IMAGE_MAX_IMAGE_BYTES,
        }
    }
}

pub(crate) fn default_view_image_max_image_bytes() -> u64 {
    DEFAULT_VIEW_IMAGE_MAX_IMAGE_BYTES
}

pub(crate) fn validate_view_image_identifier(field: &str, value: &str) -> anyhow::Result<String> {
    validate_model_identifier(&format!("tools.view_image.vision_model.{field}"), value)
}

pub(super) fn validate_model_identifier(field: &str, value: &str) -> anyhow::Result<String> {
    let trimmed = value.trim();
    if trimmed.is_empty() {
        anyhow::bail!("{field} must not be empty");
    }
    if trimmed
        .chars()
        .any(|ch| ch.is_ascii_control() || ch.is_ascii_whitespace())
    {
        anyhow::bail!("{field} must not contain ASCII whitespace or control characters");
    }
    if crate::config::looks_like_secret_value(trimmed) {
        anyhow::bail!("{field} must not look like a secret value");
    }
    Ok(trimmed.to_string())
}

pub(crate) fn validate_view_image_max_image_bytes(value: u64) -> anyhow::Result<u64> {
    if !(1..=MAX_VIEW_IMAGE_MAX_IMAGE_BYTES).contains(&value) {
        anyhow::bail!(
            "tools.view_image.max_image_bytes must be between 1 and {MAX_VIEW_IMAGE_MAX_IMAGE_BYTES}"
        );
    }
    Ok(value)
}

pub(crate) fn clamp_subagent_max_depth(max_depth: usize) -> usize {
    max_depth.clamp(1, MAX_SUBAGENT_MAX_DEPTH)
}