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}
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
96pub 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 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 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 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;