Skip to main content

artifactize_core/config/
tools.rs

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