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