Skip to main content

artifactize_core/config/
tools.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3
4/// Bound serialized declarations before validating or sending tool schemas to a provider.
5const MAX_DECLARATION_BYTES: usize = 8 * 1024 * 1024;
6/// Keep descriptions usable in provider tool listings, measured as JSON client UTF-16 units.
7const MAX_DESCRIPTION_CHARS: usize = 4000;
8
9use super::validation::{paths, present, script, text, timeout};
10
11#[derive(Debug, Clone, Serialize)]
12#[serde(untagged)]
13pub enum AgentTool {
14    Command(CommandTool),
15    Builtin(BuiltinTool),
16}
17
18impl<'de> Deserialize<'de> for AgentTool {
19    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
20        use serde::de::Error;
21        let value = Value::deserialize(deserializer)?;
22        if value.get("builtin").is_some() {
23            serde_json::from_value(value).map(Self::Builtin)
24        } else {
25            serde_json::from_value(value).map(Self::Command)
26        }
27        .map_err(D::Error::custom)
28    }
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
32#[serde(
33    rename_all(serialize = "camelCase", deserialize = "snake_case"),
34    deny_unknown_fields,
35    try_from = "CommandToolInput"
36)]
37pub struct CommandTool {
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub session: Option<super::ToolOperationName>,
40    pub description: String,
41    #[serde(default = "empty_schema")]
42    pub input_schema: Value,
43    pub protocol: ToolProtocol,
44    pub command: String,
45    pub args: Vec<crate::scope::Argument>,
46    #[serde(
47        default,
48        deserialize_with = "timeout",
49        serialize_with = "super::validation::milliseconds::serialize",
50        skip_serializing_if = "Option::is_none"
51    )]
52    pub timeout_ms: Option<std::time::Duration>,
53    #[serde(default, skip_serializing_if = "Vec::is_empty")]
54    pub execution_paths: Vec<super::LogicalPath>,
55    #[serde(skip)]
56    pub(crate) plain_args: Vec<super::plain::PlainArgument>,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
60#[serde(
61    rename_all(serialize = "camelCase", deserialize = "snake_case"),
62    deny_unknown_fields
63)]
64struct CommandToolInput {
65    #[serde(default)]
66    pub session: Option<super::ToolOperationName>,
67    pub description: String,
68    #[serde(default = "empty_schema")]
69    pub input_schema: Value,
70    pub protocol: ToolProtocol,
71    pub command: String,
72    pub args: Vec<crate::scope::Argument>,
73    #[serde(
74        default,
75        deserialize_with = "timeout",
76        serialize_with = "super::validation::milliseconds::serialize",
77        skip_serializing_if = "Option::is_none"
78    )]
79    pub timeout_ms: Option<std::time::Duration>,
80    #[serde(default, skip_serializing_if = "Vec::is_empty")]
81    pub execution_paths: Vec<super::LogicalPath>,
82}
83
84impl TryFrom<CommandToolInput> for CommandTool {
85    type Error = String;
86    fn try_from(input: CommandToolInput) -> Result<Self, Self::Error> {
87        let plain_args = if input.protocol == ToolProtocol::Plain {
88            input
89                .args
90                .iter()
91                .map(|arg| {
92                    super::plain::PlainArgument::parse(&arg.to_string(), &input.input_schema)
93                })
94                .collect::<Result<_, _>>()?
95        } else {
96            Vec::new()
97        };
98        Ok(Self {
99            session: input.session,
100            description: input.description,
101            input_schema: input.input_schema,
102            protocol: input.protocol,
103            command: input.command,
104            args: input.args,
105            timeout_ms: input.timeout_ms,
106            execution_paths: input.execution_paths,
107            plain_args,
108        })
109    }
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize)]
113#[serde(deny_unknown_fields)]
114pub struct ToolSession {
115    pub start: SessionCommand,
116    pub stop: SessionCommand,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
120#[serde(
121    rename_all(serialize = "camelCase", deserialize = "snake_case"),
122    deny_unknown_fields
123)]
124pub struct SessionCommand {
125    pub command: String,
126    pub args: Vec<crate::scope::Argument>,
127    #[serde(default, skip_serializing_if = "Vec::is_empty")]
128    pub execution_paths: Vec<super::LogicalPath>,
129}
130
131impl SessionCommand {
132    pub(super) fn validate(&self) -> Result<(), String> {
133        script(&self.command, &self.args)?;
134        if self.command.contains(['{', '}']) {
135            return Err("Session command must not contain placeholders.".into());
136        }
137        paths(&self.execution_paths, "execution_paths")
138    }
139}
140
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
142#[serde(rename_all = "lowercase")]
143pub enum ToolProtocol {
144    Json,
145    Plain,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
149#[serde(deny_unknown_fields)]
150pub struct BuiltinTool {
151    pub builtin: Builtin,
152    #[serde(
153        default,
154        deserialize_with = "present",
155        skip_serializing_if = "Option::is_none"
156    )]
157    pub args: Option<Vec<String>>,
158    #[serde(
159        default,
160        deserialize_with = "present",
161        skip_serializing_if = "Option::is_none"
162    )]
163    pub description: Option<String>,
164}
165
166pub use artifactize_tools::Builtin;
167
168impl AgentTool {
169    pub(super) fn validate(&self) -> Result<(), String> {
170        if serde_json::to_vec(self).map_err(|e| e.to_string())?.len() > MAX_DECLARATION_BYTES {
171            return Err("Tool declaration exceeds 8 MiB.".into());
172        }
173        match self {
174            Self::Command(tool) => {
175                description(&tool.description)?;
176                script(&tool.command, &tool.args)?;
177                if tool.command.contains(['{', '}']) {
178                    return Err("Tool command must not contain placeholders.".into());
179                }
180                paths(&tool.execution_paths, "execution_paths")?;
181                crate::tools::schema::compile(&tool.input_schema)?;
182                Ok(())
183            }
184            Self::Builtin(tool) => {
185                if let Some(value) = &tool.description {
186                    description(value)?;
187                }
188                artifactize_tools::builtin::validate_args(
189                    tool.builtin,
190                    tool.args.as_deref(),
191                    false,
192                )?;
193                if let Some(args) = &tool.args {
194                    validate_agent_target(tool.builtin, args)?;
195                }
196                Ok(())
197            }
198        }
199    }
200    pub fn input_schema(&self) -> Value {
201        match self {
202            Self::Command(tool) => tool.input_schema.clone(),
203            Self::Builtin(tool) => match &tool.args {
204                Some(args) => crate::tools::builtin::fixed_schema(tool.builtin, args),
205                None => crate::tools::builtin::input_schema(tool.builtin),
206            },
207        }
208    }
209}
210
211#[derive(Debug, Clone, Serialize)]
212#[serde(untagged)]
213pub enum HumanTool {
214    Command(HumanCommandTool),
215    Builtin(HumanBuiltinTool),
216}
217
218impl<'de> Deserialize<'de> for HumanTool {
219    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
220        use serde::de::Error;
221        let value = Value::deserialize(deserializer)?;
222        if value.get("builtin").is_some() {
223            serde_json::from_value(value).map(Self::Builtin)
224        } else {
225            serde_json::from_value(value).map(Self::Command)
226        }
227        .map_err(D::Error::custom)
228    }
229}
230
231#[derive(Debug, Clone, Serialize, Deserialize)]
232#[serde(
233    rename_all(serialize = "camelCase", deserialize = "snake_case"),
234    deny_unknown_fields
235)]
236pub struct HumanCommandTool {
237    pub description: String,
238    pub kind: HumanToolKind,
239    pub command: String,
240    #[serde(deserialize_with = "crate::scope::human_arguments")]
241    pub args: Vec<crate::scope::Argument>,
242    #[serde(
243        default,
244        deserialize_with = "timeout",
245        serialize_with = "super::validation::milliseconds::serialize",
246        skip_serializing_if = "Option::is_none"
247    )]
248    pub timeout_ms: Option<std::time::Duration>,
249}
250
251#[derive(Debug, Clone)]
252pub struct HumanBuiltinTool {
253    pub builtin: Builtin,
254    pub description: String,
255    pub args: Vec<crate::scope::Argument>,
256}
257
258impl<'de> Deserialize<'de> for HumanBuiltinTool {
259    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
260        use serde::de::Error;
261        #[derive(Deserialize)]
262        #[serde(deny_unknown_fields)]
263        struct Declaration {
264            builtin: Builtin,
265            #[serde(default, deserialize_with = "present")]
266            description: Option<String>,
267            #[serde(default, deserialize_with = "present")]
268            kind: Option<HumanToolKind>,
269            #[serde(deserialize_with = "crate::scope::human_arguments")]
270            args: Vec<crate::scope::Argument>,
271        }
272        let declaration = Declaration::deserialize(deserializer)?;
273        let args: Vec<String> = declaration.args.iter().map(ToString::to_string).collect();
274        artifactize_tools::builtin::validate_args(declaration.builtin, Some(&args), true)
275            .map_err(D::Error::custom)?;
276        let kind = if matches!(declaration.builtin, Builtin::Open | Builtin::Graph) {
277            HumanToolKind::Launch
278        } else {
279            HumanToolKind::Output
280        };
281        if declaration.kind.is_some_and(|declared| declared != kind) {
282            return Err(D::Error::custom(
283                "Human builtin kind disagrees with its tool.",
284            ));
285        }
286        Ok(Self {
287            builtin: declaration.builtin,
288            description: declaration.description.unwrap_or_else(|| {
289                crate::tools::builtin::fixed_description(declaration.builtin, &args).into()
290            }),
291            args: declaration.args,
292        })
293    }
294}
295
296#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
297#[serde(rename_all = "lowercase")]
298pub enum HumanToolKind {
299    Launch,
300    Output,
301}
302
303impl HumanTool {
304    pub fn description(&self) -> &str {
305        match self {
306            Self::Command(tool) => &tool.description,
307            Self::Builtin(tool) => &tool.description,
308        }
309    }
310    pub fn kind(&self) -> HumanToolKind {
311        match self {
312            Self::Command(tool) => tool.kind,
313            Self::Builtin(tool) => tool.kind(),
314        }
315    }
316    pub(super) fn validate(&self) -> Result<(), String> {
317        description(self.description())?;
318        match self {
319            Self::Command(tool) => {
320                script(&tool.command, &tool.args)?;
321                if tool.command.contains(['{', '}']) {
322                    return Err("Tool command must not contain placeholders.".into());
323                }
324                for argument in &tool.args {
325                    crate::scope::validate_human_argument(argument)
326                        .map_err(|error| error.to_string())?;
327                }
328            }
329            Self::Builtin(tool) => {
330                for argument in &tool.args {
331                    crate::scope::validate_human_argument(argument)
332                        .map_err(|error| error.to_string())?;
333                }
334                if !matches!(tool.builtin, Builtin::Help | Builtin::Graph)
335                    && let crate::scope::Argument::Literal(target) = &tool.args[0]
336                    && !(tool.builtin == Builtin::Open
337                        && artifactize_tools::builtin::is_url(target))
338                {
339                    artifactize_tools::scope::logical_path(target)
340                        .map_err(|error| error.to_string())?;
341                }
342                if tool.builtin == Builtin::Help
343                    && tool
344                        .args
345                        .iter()
346                        .any(|arg| matches!(arg, crate::scope::Argument::Reference { .. }))
347                {
348                    return Err(
349                        "Help args are literal program/subcommand names, not path placeholders."
350                            .into(),
351                    );
352                }
353            }
354        }
355        Ok(())
356    }
357}
358
359fn validate_agent_target(builtin: Builtin, args: &[String]) -> Result<(), String> {
360    if builtin == Builtin::Graph {
361        return Ok(());
362    }
363    if builtin == Builtin::Help {
364        if args.iter().any(|arg| arg.contains(['{', '}'])) {
365            return Err("Help args are literal program/subcommand names.".into());
366        }
367        return Ok(());
368    }
369    artifactize_tools::scope::logical_path(&args[0]).map_err(|error| error.to_string())
370}
371
372fn empty_schema() -> Value {
373    serde_json::json!({"type":"object", "additionalProperties":false})
374}
375
376pub(super) fn description(value: &str) -> Result<(), String> {
377    text(value, "Tool description")?;
378    if value.encode_utf16().count() > MAX_DESCRIPTION_CHARS
379        || value.replace("{artifactName}", "").contains(['{', '}'])
380    {
381        return Err(
382            "Tool description is limited to 4000 characters and supports only {artifactName}."
383                .into(),
384        );
385    }
386    Ok(())
387}
388
389impl HumanBuiltinTool {
390    pub fn kind(&self) -> HumanToolKind {
391        if matches!(self.builtin, Builtin::Open | Builtin::Graph) {
392            HumanToolKind::Launch
393        } else {
394            HumanToolKind::Output
395        }
396    }
397}
398impl Serialize for HumanBuiltinTool {
399    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
400        #[derive(Serialize)]
401        struct Stored<'a> {
402            builtin: Builtin,
403            description: &'a str,
404            kind: HumanToolKind,
405            args: &'a [crate::scope::Argument],
406        }
407        Stored {
408            builtin: self.builtin,
409            description: &self.description,
410            kind: self.kind(),
411            args: &self.args,
412        }
413        .serialize(serializer)
414    }
415}