Skip to main content

artifactize_core/
tools.rs

1//! Scoped audience-specific tools and language-neutral command protocols.
2
3pub use artifactize_tools::builtin;
4pub mod human;
5#[cfg(test)]
6mod image;
7pub mod pins;
8mod result;
9pub mod sessions;
10pub(crate) use artifactize_tools::schema;
11
12pub use artifactize_tools::{Content, ToolResult};
13
14use std::{
15    collections::BTreeMap,
16    ffi::OsString,
17    path::{Path, PathBuf},
18};
19
20use serde::Serialize;
21use serde_json::{Value, json};
22use tokio_util::sync::CancellationToken;
23
24use crate::{
25    config::{AgentTool, CommandTool, Profile, RepoConfig, ToolProtocol},
26    runtime,
27    scope::{self, ArtifactId, Scope},
28    workspace,
29};
30
31/// A command tool gets two minutes unless its owner declares a deadline.
32const DEFAULT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
33/// Capture enough for an 8 MiB normalized multimodal result plus its wire envelope,
34/// but bound raw tool stdout/stderr; truncated JSON is rejected rather than silently reused.
35const OUTPUT_LIMIT: usize = 16 * 1024 * 1024;
36/// Version 1 identifies the JSON command-tool stdin envelope carrying context and args;
37/// owner tools can distinguish future envelopes independently of fingerprint or state formats.
38const JSON_TOOL_INPUT_VERSION: u32 = 1;
39
40#[derive(Debug, Clone, Serialize)]
41#[serde(rename_all = "camelCase")]
42pub struct ToolDefinition {
43    pub name: crate::config::ToolName,
44    pub artifact_id: crate::config::ArtifactName,
45    pub description: String,
46    pub input_schema: Value,
47}
48
49struct RegisteredTool<'a> {
50    definition: ToolDefinition,
51    declaration: &'a AgentTool,
52    validator: jsonschema::Validator,
53}
54
55#[derive(Serialize)]
56#[serde(rename_all = "camelCase")]
57struct ToolContext {
58    artifact_id: crate::config::ArtifactName,
59    #[serde(with = "crate::platform::path_serde")]
60    artifact_path: PathBuf,
61    scope: BTreeMap<crate::config::ArtifactName, ContextArtifact>,
62    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
63    execution_paths: BTreeMap<crate::config::LogicalPath, ContextPath>,
64    #[serde(
65        default,
66        skip_serializing_if = "Option::is_none",
67        with = "crate::platform::path_serde::option"
68    )]
69    output_dir: Option<PathBuf>,
70    #[serde(
71        default,
72        skip_serializing_if = "Option::is_none",
73        with = "crate::platform::path_serde::option"
74    )]
75    tmp_dir: Option<PathBuf>,
76}
77#[derive(Serialize)]
78struct ContextArtifact {
79    #[serde(with = "crate::platform::path_serde")]
80    path: PathBuf,
81    kind: crate::config::ArtifactKind,
82    children: BTreeMap<crate::config::ChildPrefix, crate::config::ArtifactName>,
83    mounts: BTreeMap<crate::config::MountAlias, crate::config::ArtifactName>,
84}
85#[derive(Serialize)]
86struct ContextPath(#[serde(with = "crate::platform::path_serde")] PathBuf);
87
88struct Invocation {
89    cwd: PathBuf,
90    program: OsString,
91    argv: Vec<OsString>,
92    context: ToolContext,
93    protocol: ToolProtocol,
94    timeout_ms: std::time::Duration,
95}
96
97/// Borrows the resolved declarations; discovery and listing never execute owner code.
98/// Calls own their temporary directories, leaving the caller's output root intact.
99pub struct Registry<'a> {
100    config: &'a RepoConfig,
101    scope: Scope<'a>,
102    tools: BTreeMap<crate::config::ToolName, RegisteredTool<'a>>,
103    sessions: Option<&'a sessions::Sessions>,
104}
105
106impl<'a> Registry<'a> {
107    pub fn new(config: &'a RepoConfig, eval_id: &str) -> Result<Self, String> {
108        let eval = config
109            .evals
110            .iter()
111            .find(|eval| eval.id == eval_id)
112            .ok_or_else(|| format!("Unknown eval: {eval_id}"))?;
113        if !matches!(eval.declaration.profile(), Profile::Agent { .. }) {
114            return Err("Agent tools require an Agent eval.".into());
115        }
116        Self::with_scope(
117            config,
118            scope::eval_scope(config, eval).map_err(|e| e.to_string())?,
119        )
120    }
121
122    pub fn for_artifact(config: &'a RepoConfig, artifact_id: &str) -> Result<Self, String> {
123        let artifact_id: crate::config::ArtifactName = artifact_id.parse()?;
124        Self::with_scope(
125            config,
126            scope::artifact_scope(config, &[&artifact_id]).map_err(|e| e.to_string())?,
127        )
128    }
129
130    fn with_scope(config: &'a RepoConfig, scope: Scope<'a>) -> Result<Self, String> {
131        let mut tools = BTreeMap::new();
132        let mut session_names = BTreeMap::new();
133        for (id, artifact) in &scope.artifacts {
134            for (operation, declaration) in &artifact.views.agent_tools {
135                if let AgentTool::Command(command) = declaration
136                    && let Some(session) = &command.session
137                {
138                    let key = format!("{session}_{id}");
139                    let identity = (id, session);
140                    if session_names
141                        .insert(key.clone(), identity)
142                        .is_some_and(|previous| previous != identity)
143                    {
144                        return Err(format!("Artifact tool session names collide: {key}"));
145                    }
146                }
147                let name: crate::config::ToolName = format!("{operation}_{id}")
148                    .parse()
149                    .expect("validated tool and Artifact names");
150                let description = match declaration {
151                    AgentTool::Command(tool) => tool.description.clone(),
152                    AgentTool::Builtin(tool) => {
153                        tool.description
154                            .clone()
155                            .unwrap_or_else(|| match &tool.args {
156                                Some(args) => builtin::fixed_description(tool.builtin, args).into(),
157                                None => builtin::description(tool.builtin).into(),
158                            })
159                    }
160                }
161                .replace("{artifactName}", id);
162                let input_schema = declaration.input_schema();
163                let validator = schema::compile(&input_schema)?;
164                let entry = RegisteredTool {
165                    definition: ToolDefinition {
166                        name: name.clone(),
167                        artifact_id: artifact.name.clone(),
168                        description,
169                        input_schema,
170                    },
171                    declaration,
172                    validator,
173                };
174                if tools.insert(name.clone(), entry).is_some() {
175                    return Err(format!("Artifact tool names collide: {name}"));
176                }
177            }
178        }
179        Ok(Self {
180            config,
181            scope,
182            tools,
183            sessions: None,
184        })
185    }
186
187    pub(crate) fn with_session_scope<'b>(
188        &'b self,
189        sessions: &'b sessions::Sessions,
190    ) -> Result<Registry<'b>, String> {
191        Registry::with_scope(self.config, self.scope.clone())
192            .map(|registry| registry.with_sessions(sessions))
193    }
194
195    pub fn with_sessions(mut self, sessions: &'a sessions::Sessions) -> Self {
196        self.sessions = Some(sessions);
197        self
198    }
199
200    pub fn list(&self) -> impl Iterator<Item = &ToolDefinition> {
201        self.tools.values().map(|tool| &tool.definition)
202    }
203
204    /// Resolve static prerequisites without substituting free arguments.
205    pub fn preflight(&self, name: &str) -> Result<(), String> {
206        let tool = self
207            .tools
208            .get(name)
209            .ok_or("Unknown registered Agent tool.")?;
210        let owner = &tool.definition.artifact_id;
211        self.scope
212            .resolve_input(&self.config.root, owner, "")
213            .map_err(|e| e.to_string())?;
214        if let AgentTool::Command(command) = tool.declaration {
215            preflight_executable(&self.config.root, &self.scope, owner, &command.command)?;
216            if let Some(name) = &command.session {
217                let session = &self.scope.artifacts[owner].views.agent_sessions[name];
218                for command in [&session.start, &session.stop] {
219                    preflight_executable(&self.config.root, &self.scope, owner, &command.command)?;
220                    scope::resolve_argv(self.config, &self.scope, owner, &command.args)
221                        .map_err(|error| error.to_string())?;
222                    for path in &command.execution_paths {
223                        scope::scoped_path(&self.config.root, Path::new(path))
224                            .map_err(|error| error.to_string())?;
225                    }
226                }
227            }
228            if matches!(command.protocol, ToolProtocol::Json) {
229                scope::resolve_argv(self.config, &self.scope, owner, &command.args)
230                    .map_err(|e| e.to_string())?;
231            }
232            for path in &command.execution_paths {
233                scope::scoped_path(&self.config.root, Path::new(path))
234                    .map_err(|e| e.to_string())?;
235            }
236        }
237        if let AgentTool::Builtin(declaration) = tool.declaration
238            && let Some(args) = &declaration.args
239        {
240            let owner = ArtifactId::new(owner.as_str()).map_err(|error| error.to_string())?;
241            builtin::validate_target(
242                declaration.builtin,
243                args,
244                &self.config.root,
245                &self.scope.tool_scope(),
246                &owner,
247            )?;
248        }
249        Ok(())
250    }
251
252    pub async fn call(
253        &self,
254        name: &str,
255        args: Value,
256        output_root: &Path,
257        cancellation: CancellationToken,
258    ) -> ToolResult {
259        let Some(tool) = self.tools.get(name) else {
260            return ToolResult::error("Unknown registered Agent tool.");
261        };
262        if let Err(message) = schema::validate(&tool.validator, &args) {
263            return ToolResult::error(message);
264        }
265        if cancellation.is_cancelled() {
266            return ToolResult::error("Agent tool call was cancelled.");
267        }
268        for artifact in self.scope.artifacts.values() {
269            if let Err(error) = scope::validate_file_target(&self.config.root, artifact) {
270                return ToolResult::error(error.to_string());
271            }
272        }
273        let command = match tool.declaration {
274            AgentTool::Command(command) => command,
275            AgentTool::Builtin(tool_declaration) => {
276                if tool_declaration.builtin == crate::config::Builtin::Graph {
277                    return match crate::graph_structure::read(&self.config.root) {
278                        Ok(graph) => ToolResult::success(Content::Text { text: graph.tree() }),
279                        Err(error) => ToolResult::error(error),
280                    };
281                }
282                if let Some(fixed_args) = &tool_declaration.args {
283                    let owner = scope::tool_id(&tool.definition.artifact_id);
284                    // An Agent's help program gets only the explicit standalone variables.
285                    let launcher = crate::process::Launcher {
286                        env: artifactize_tools::launch::passed_environment(),
287                    };
288                    let scope = self.scope.tool_scope();
289                    let context = builtin::Context {
290                        root: &self.config.root,
291                        scope: &scope,
292                        owner: &owner,
293                        launcher: &launcher,
294                    };
295                    return builtin::call_fixed(
296                        tool_declaration.builtin,
297                        fixed_args,
298                        args,
299                        context,
300                        &cancellation,
301                    )
302                    .await;
303                }
304                let input = match builtin::Input::parse(tool_declaration.builtin, args) {
305                    Ok(input) => input,
306                    Err(error) => return ToolResult::error(error),
307                };
308                let root = self.config.root.clone();
309                let owner = scope::tool_id(&tool.definition.artifact_id);
310                let scope = self.scope.tool_scope();
311                let cancellation = cancellation.child_token();
312                let _cancel_on_drop = cancellation.clone().drop_guard();
313                return crate::task::joined(
314                    tokio::task::spawn_blocking(move || {
315                        builtin::call(input, &root, &scope, &owner, &cancellation)
316                    })
317                    .await,
318                )
319                .unwrap_or_else(|_| ToolResult::error("Built-in Agent tool execution failed."));
320            }
321        };
322        let environment = if let Some(name) = &command.session {
323            let key = sessions::session_key(name, &tool.definition.artifact_id);
324            match self.sessions.and_then(|sessions| sessions.call(&key)) {
325                Some(call) => Some(call),
326                None => return ToolResult::error(format!("The tool session `{name}` ended.")),
327            }
328        } else {
329            None
330        };
331        let invocation = match self.prepare(&tool.definition.artifact_id, command, &args) {
332            Ok(invocation) => invocation,
333            Err(()) => return ToolResult::error("Agent tool preparation failed."),
334        };
335        let workspace = self.config.root.clone();
336        let output_root = output_root.to_owned();
337        let cancellation = cancellation.child_token();
338        let _cancel_on_drop = cancellation.clone().drop_guard();
339        // Keep cleanup alive if the caller drops its future while a command is running.
340        let executor = if environment.is_some() {
341            match sessions::executor() {
342                Ok(executor) => executor.clone(),
343                Err(_) => return ToolResult::error("Agent tool execution failed."),
344            }
345        } else {
346            tokio::runtime::Handle::current()
347        };
348        crate::task::joined(
349            executor
350                .spawn(async move {
351                    invoke(
352                        invocation,
353                        args,
354                        &workspace,
355                        &output_root,
356                        cancellation,
357                        environment,
358                    )
359                    .await
360                })
361                .await,
362        )
363        .unwrap_or_else(|_| ToolResult::error("Agent tool execution failed."))
364    }
365
366    fn prepare_session(
367        &self,
368        owner: &crate::config::ArtifactName,
369        command: &crate::config::SessionCommand,
370    ) -> Result<Invocation, String> {
371        let tool = CommandTool {
372            session: None,
373            description: String::new(),
374            input_schema: json!({"type":"object"}),
375            protocol: ToolProtocol::Json,
376            command: command.command.clone(),
377            args: command.args.clone(),
378            timeout_ms: None,
379            execution_paths: command.execution_paths.clone(),
380            plain_args: Vec::new(),
381        };
382        self.prepare(owner, &tool, &json!({}))
383            .map_err(|()| "Tool session preparation failed.".into())
384    }
385
386    fn prepare(
387        &self,
388        owner: &crate::config::ArtifactName,
389        tool: &CommandTool,
390        args: &Value,
391    ) -> Result<Invocation, ()> {
392        let cwd = scope::scoped_path(&self.config.root, self.config.artifacts[owner].folder())
393            .map_err(|_| ())?;
394        let program = executable(&self.config.root, &self.scope, owner, &tool.command)?;
395        let argv = match tool.protocol {
396            ToolProtocol::Json => {
397                scope::resolve_argv(self.config, &self.scope, owner, &tool.args).map_err(|_| ())?
398            }
399            ToolProtocol::Plain => tool
400                .plain_args
401                .iter()
402                .map(|arg| {
403                    arg.expand(|name| {
404                        if tool.input_schema["properties"].get(name).is_none() {
405                            return Err("Undeclared plain tool placeholder.".into());
406                        }
407                        let value = args.get(name).ok_or("Missing plain tool argument.")?;
408                        let value = match value {
409                            Value::String(value) => value.clone(),
410                            _ => value.to_string(),
411                        };
412                        if value.contains('\0') {
413                            return Err("Plain tool arguments cannot contain NUL.".into());
414                        }
415                        Ok(value)
416                    })
417                })
418                .collect::<Result<Vec<_>, _>>()
419                .map_err(|_| ())?,
420        };
421        let scoped = self
422            .scope
423            .artifacts
424            .values()
425            .map(|artifact| {
426                let path = scope::scoped_path(&self.config.root, &artifact.path).map_err(|_| ())?;
427                Ok((
428                    artifact.name.clone(),
429                    ContextArtifact {
430                        path,
431                        kind: artifact.kind,
432                        children: artifact.children.clone(),
433                        mounts: artifact.mounts.clone(),
434                    },
435                ))
436            })
437            .collect::<Result<BTreeMap<_, _>, ()>>()?;
438        let execution_paths = tool
439            .execution_paths
440            .iter()
441            .map(|path| {
442                let resolved =
443                    scope::scoped_path(&self.config.root, Path::new(path)).map_err(|_| ())?;
444                Ok((path.clone(), ContextPath(resolved)))
445            })
446            .collect::<Result<_, ()>>()?;
447        let owner = &self.scope.artifacts[owner].name;
448        let context = ToolContext {
449            artifact_id: owner.clone(),
450            artifact_path: scoped[owner].path.clone(),
451            scope: scoped,
452            execution_paths,
453            output_dir: None,
454            tmp_dir: None,
455        };
456        Ok(Invocation {
457            cwd,
458            program,
459            argv: argv.into_iter().map(Into::into).collect(),
460            context,
461            protocol: tool.protocol,
462            timeout_ms: tool.timeout_ms.unwrap_or(DEFAULT_TIMEOUT),
463        })
464    }
465}
466
467fn executable(
468    root: &Path,
469    scope: &Scope<'_>,
470    owner: &crate::config::ArtifactName,
471    command: &str,
472) -> Result<OsString, ()> {
473    scope::executable(root, scope, owner, command).map_err(|_| ())
474}
475
476fn preflight_executable(
477    root: &Path,
478    scope: &Scope<'_>,
479    owner: &crate::config::ArtifactName,
480    command: &str,
481) -> Result<(), String> {
482    let program = executable(root, scope, owner, command)
483        .map_err(|_| "Tool executable path is unavailable or outside scope.".to_owned())?;
484    let cwd = scope::scoped_path(root, scope.artifacts[owner].folder())
485        .map_err(|error| error.to_string())?;
486    crate::platform::program::resolve(&program, &cwd)
487        .map(|_| ())
488        .map_err(|_| {
489            format!(
490                "Tool executable is unavailable: {}",
491                crate::platform::path_text(Path::new(command))
492            )
493        })
494}
495
496async fn invoke(
497    invocation: Invocation,
498    args: Value,
499    workspace: &Path,
500    output_root: &Path,
501    cancellation: CancellationToken,
502    environment: Option<sessions::ownership::Call>,
503) -> ToolResult {
504    let run = || {
505        let root = workspace::prepare_directory(output_root, workspace).map_err(|_| ())?;
506        tempfile::Builder::new()
507            .prefix("tool-")
508            .tempdir_in(root)
509            .map_err(|_| ())
510    };
511    let Ok(directory) = run() else {
512        return ToolResult::error("Agent tool preparation failed.");
513    };
514    let Invocation {
515        cwd,
516        program,
517        argv,
518        mut context,
519        protocol,
520        timeout_ms,
521    } = invocation;
522    let result = async {
523        let mut command =
524            runtime::Command::prepare(program, argv, workspace, directory.path(), Some(timeout_ms))
525                .map_err(|_| "Agent tool preparation failed.")?;
526        command.cwd = cwd;
527        if let Some(environment) = &environment {
528            command.inherit_session(&environment.environment);
529        }
530        let output_dir = command.directory().join("output");
531        context.output_dir = Some(output_dir.clone());
532        context.tmp_dir = Some(command.directory().join("tmp"));
533        let input = match protocol {
534            ToolProtocol::Json => {
535                json!({"version":JSON_TOOL_INPUT_VERSION,"context":context,"args":args})
536                    .to_string()
537                    .into_bytes()
538            }
539            ToolProtocol::Plain => Vec::new(),
540        };
541        let output = match environment {
542            Some(call) => {
543                call.run(
544                    command.into_process(),
545                    input,
546                    OUTPUT_LIMIT,
547                    cancellation.clone(),
548                )
549                .await
550            }
551            None => {
552                command
553                    .tool_output(input, OUTPUT_LIMIT, cancellation.clone())
554                    .await
555            }
556        }
557        .map_err(|error| {
558            error.argument_refusal().unwrap_or_else(|| match error {
559                crate::process::Error::Timeout => "Agent tool timed out.".into(),
560                crate::process::Error::Cancelled => "Agent tool call was cancelled.".into(),
561                _ => "Agent tool execution failed.".into(),
562            })
563        })?;
564        match protocol {
565            ToolProtocol::Json => {
566                if !output.status.success() {
567                    return Err("Agent tool execution failed.".to_owned());
568                }
569                if output.truncated {
570                    return Err("Agent tool returned invalid output.".to_owned());
571                }
572                result::parse(&output.stdout, &output_dir)
573                    .map_err(|_| "Agent tool returned invalid output.".to_owned())
574            }
575            ToolProtocol::Plain => Ok(result::plain(&output)),
576        }
577    }
578    .await;
579    let cleaned = directory.close();
580    if cancellation.is_cancelled() {
581        return ToolResult::error("Agent tool call was cancelled.");
582    }
583    if cleaned.is_err() {
584        return ToolResult::error("Agent tool cleanup failed.");
585    }
586    result.unwrap_or_else(ToolResult::error)
587}
588
589#[cfg(test)]
590mod tests;
591
592#[cfg(test)]
593#[path = "tools/builtin/tests.rs"]
594mod builtin_tests;