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 nested: BTreeMap<crate::config::ChildPrefix, crate::config::ArtifactName>,
83 #[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
99pub 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 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 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 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;