1pub 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
31const DEFAULT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
33const OUTPUT_LIMIT: usize = 16 * 1024 * 1024;
36const 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
97pub 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 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 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 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;