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