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