Skip to main content

magi_code/config/settings/
tools.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4use crate::subagents::{DEFAULT_SUBAGENT_MAX_DEPTH, MAX_SUBAGENT_MAX_DEPTH};
5
6use super::helpers::default_true;
7
8#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
9pub struct ToolOutputCompressionSettings {
10    #[serde(default)]
11    pub enabled: bool,
12}
13
14impl ToolOutputCompressionSettings {
15    pub(crate) fn is_default(&self) -> bool {
16        self == &Self::default()
17    }
18}
19
20#[derive(Debug, Clone, Default, PartialEq, Eq)]
21pub struct ToolSettings {
22    pub read: ReadToolSettings,
23    pub view_image: ViewImageToolSettings,
24    pub hash_edit: HashEditToolSettings,
25    pub write: WriteToolSettings,
26    pub grep: GrepToolSettings,
27    pub find: FindToolSettings,
28    pub list_files: ListFilesToolSettings,
29    pub ast_grep: AstGrepToolSettings,
30    pub bash: BashToolSettings,
31    pub subagents: SubagentsToolSettings,
32    pub output_compression: ToolOutputCompressionSettings,
33    pub disabled: Vec<String>,
34}
35
36#[derive(Serialize, Deserialize, JsonSchema)]
37struct ToolSettingsWire {
38    #[serde(default, skip_serializing_if = "ReadToolSettings::is_default")]
39    #[schemars(!skip_serializing_if)]
40    read: ReadToolSettings,
41    #[serde(default, skip_serializing_if = "ViewImageToolSettings::is_default")]
42    #[schemars(!skip_serializing_if)]
43    view_image: ViewImageToolSettings,
44    #[serde(default, skip_serializing_if = "HashEditToolSettings::is_default")]
45    #[schemars(!skip_serializing_if)]
46    hash_edit: HashEditToolSettings,
47    #[serde(default, skip_serializing_if = "WriteToolSettings::is_default")]
48    #[schemars(!skip_serializing_if)]
49    write: WriteToolSettings,
50    #[serde(default, skip_serializing_if = "GrepToolSettings::is_default")]
51    #[schemars(!skip_serializing_if)]
52    grep: GrepToolSettings,
53    #[serde(default, skip_serializing_if = "FindToolSettings::is_default")]
54    #[schemars(!skip_serializing_if)]
55    find: FindToolSettings,
56    #[serde(default, skip_serializing_if = "ListFilesToolSettings::is_default")]
57    #[schemars(!skip_serializing_if)]
58    list_files: ListFilesToolSettings,
59    #[serde(default, skip_serializing_if = "AstGrepToolSettings::is_default")]
60    #[schemars(!skip_serializing_if)]
61    ast_grep: AstGrepToolSettings,
62    #[serde(default, skip_serializing_if = "BashToolSettings::is_default")]
63    #[schemars(!skip_serializing_if)]
64    bash: BashToolSettings,
65    #[serde(default, skip_serializing_if = "SubagentsToolSettings::is_default")]
66    subagents: SubagentsToolSettings,
67    #[serde(
68        default,
69        skip_serializing_if = "ToolOutputCompressionSettings::is_default"
70    )]
71    #[schemars(!skip_serializing_if)]
72    output_compression: ToolOutputCompressionSettings,
73    #[serde(default, skip_serializing_if = "Vec::is_empty")]
74    #[schemars(!skip_serializing_if)]
75    disabled: Vec<String>,
76}
77
78impl From<ToolSettingsWire> for ToolSettings {
79    fn from(wire: ToolSettingsWire) -> Self {
80        Self {
81            read: wire.read,
82            view_image: wire.view_image,
83            hash_edit: wire.hash_edit,
84            write: wire.write,
85            grep: wire.grep,
86            find: wire.find,
87            list_files: wire.list_files,
88            ast_grep: wire.ast_grep,
89            bash: wire.bash,
90            subagents: wire.subagents,
91            output_compression: wire.output_compression,
92            disabled: wire.disabled,
93        }
94    }
95}
96
97impl From<&ToolSettings> for ToolSettingsWire {
98    fn from(settings: &ToolSettings) -> Self {
99        Self {
100            read: settings.read.clone(),
101            view_image: settings.view_image.clone(),
102            hash_edit: settings.hash_edit.clone(),
103            write: settings.write.clone(),
104            grep: settings.grep.clone(),
105            find: settings.find.clone(),
106            list_files: settings.list_files.clone(),
107            ast_grep: settings.ast_grep.clone(),
108            bash: settings.bash.clone(),
109            subagents: settings.subagents.clone(),
110            output_compression: settings.output_compression.clone(),
111            disabled: settings.disabled.to_vec(),
112        }
113    }
114}
115
116impl Serialize for ToolSettings {
117    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
118    where
119        S: serde::Serializer,
120    {
121        ToolSettingsWire::from(self).serialize(serializer)
122    }
123}
124
125impl<'de> Deserialize<'de> for ToolSettings {
126    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
127    where
128        D: serde::Deserializer<'de>,
129    {
130        ToolSettingsWire::deserialize(deserializer).map(Into::into)
131    }
132}
133
134impl JsonSchema for ToolSettings {
135    fn schema_name() -> std::borrow::Cow<'static, str> {
136        "ToolSettings".into()
137    }
138
139    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
140        ToolSettingsWire::json_schema(generator)
141    }
142}
143
144macro_rules! absolute_path_tool_settings {
145    ($($name:ident),+ $(,)?) => {
146        $(
147            #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
148            pub struct $name {
149                #[serde(default = "default_true")]
150                pub absolute_paths: bool,
151            }
152
153            impl Default for $name {
154                fn default() -> Self {
155                    Self {
156                        absolute_paths: true,
157                    }
158                }
159            }
160            impl $name {
161                pub(crate) fn is_default(&self) -> bool {
162                    self == &Self::default()
163                }
164            }
165        )+
166    };
167}
168
169absolute_path_tool_settings!(
170    ReadToolSettings,
171    HashEditToolSettings,
172    WriteToolSettings,
173    GrepToolSettings,
174    FindToolSettings,
175    ListFilesToolSettings,
176    AstGrepToolSettings,
177);
178
179#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
180pub struct SubagentsToolSettings {
181    #[serde(default = "default_true")]
182    pub absolute_paths: bool,
183    #[serde(default = "default_subagent_max_depth")]
184    pub max_depth: usize,
185}
186
187impl Default for SubagentsToolSettings {
188    fn default() -> Self {
189        Self {
190            absolute_paths: true,
191            max_depth: DEFAULT_SUBAGENT_MAX_DEPTH,
192        }
193    }
194}
195
196impl SubagentsToolSettings {
197    pub(crate) fn is_default(&self) -> bool {
198        self == &Self::default()
199    }
200}
201
202fn default_subagent_max_depth() -> usize {
203    DEFAULT_SUBAGENT_MAX_DEPTH
204}
205
206#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
207pub struct BashToolSettings {
208    #[serde(default = "default_true")]
209    pub absolute_paths: bool,
210    #[serde(default = "default_true")]
211    pub shell_expansion: bool,
212}
213
214impl Default for BashToolSettings {
215    fn default() -> Self {
216        Self {
217            absolute_paths: true,
218            shell_expansion: true,
219        }
220    }
221}
222
223impl BashToolSettings {
224    pub(crate) fn is_default(&self) -> bool {
225        self == &Self::default()
226    }
227}
228
229pub const DEFAULT_VIEW_IMAGE_MAX_IMAGE_BYTES: u64 = 5 * 1024 * 1024;
230pub const MAX_VIEW_IMAGE_MAX_IMAGE_BYTES: u64 = 20 * 1024 * 1024;
231
232#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
233pub struct ViewImageToolSettings {
234    #[serde(default, skip_serializing_if = "Option::is_none")]
235    pub vision_model: Option<ViewImageVisionModelSettings>,
236    #[serde(default = "default_true")]
237    pub absolute_paths: bool,
238    #[serde(default = "default_view_image_max_image_bytes")]
239    pub max_image_bytes: u64,
240}
241
242#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
243pub struct ViewImageVisionModelSettings {
244    pub provider: String,
245    pub model: String,
246}
247
248impl ViewImageToolSettings {
249    pub(crate) fn is_default(&self) -> bool {
250        self == &Self::default()
251    }
252}
253
254impl Default for ViewImageToolSettings {
255    fn default() -> Self {
256        Self {
257            vision_model: None,
258            absolute_paths: true,
259            max_image_bytes: DEFAULT_VIEW_IMAGE_MAX_IMAGE_BYTES,
260        }
261    }
262}
263
264pub(crate) fn default_view_image_max_image_bytes() -> u64 {
265    DEFAULT_VIEW_IMAGE_MAX_IMAGE_BYTES
266}
267
268pub(crate) fn validate_view_image_identifier(field: &str, value: &str) -> anyhow::Result<String> {
269    validate_model_identifier(&format!("tools.view_image.vision_model.{field}"), value)
270}
271
272pub(super) fn validate_model_identifier(field: &str, value: &str) -> anyhow::Result<String> {
273    let trimmed = value.trim();
274    if trimmed.is_empty() {
275        anyhow::bail!("{field} must not be empty");
276    }
277    if trimmed
278        .chars()
279        .any(|ch| ch.is_ascii_control() || ch.is_ascii_whitespace())
280    {
281        anyhow::bail!("{field} must not contain ASCII whitespace or control characters");
282    }
283    if crate::config::looks_like_secret_value(trimmed) {
284        anyhow::bail!("{field} must not look like a secret value");
285    }
286    Ok(trimmed.to_string())
287}
288
289pub(crate) fn validate_view_image_max_image_bytes(value: u64) -> anyhow::Result<u64> {
290    if !(1..=MAX_VIEW_IMAGE_MAX_IMAGE_BYTES).contains(&value) {
291        anyhow::bail!(
292            "tools.view_image.max_image_bytes must be between 1 and {MAX_VIEW_IMAGE_MAX_IMAGE_BYTES}"
293        );
294    }
295    Ok(value)
296}
297
298pub(crate) fn clamp_subagent_max_depth(max_depth: usize) -> usize {
299    max_depth.clamp(1, MAX_SUBAGENT_MAX_DEPTH)
300}