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