1pub mod log;
4
5use std::{collections::BTreeMap, ffi::OsString, path::PathBuf};
6
7use serde::{Deserialize, Serialize};
8use tokio_util::sync::CancellationToken;
9
10use crate::{
11 config::{HumanTool, HumanToolKind, Profile, RepoConfig},
12 process, runtime,
13 scope::{self, Scope},
14};
15
16use super::{DEFAULT_TIMEOUT, executable};
17use log::{End, Run, Stream};
18
19const TEXT_LIMIT: usize = 64 * 1024;
21
22#[derive(Debug, Clone, Serialize)]
23#[serde(rename_all = "camelCase")]
24pub struct ToolDefinition {
25 pub name: crate::config::ToolName,
26 pub artifact_id: crate::config::ArtifactName,
27 pub description: String,
28 pub kind: HumanToolKind,
29}
30
31#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32#[serde(tag = "type", rename_all = "lowercase", deny_unknown_fields)]
33pub enum Content {
34 Text { text: String },
35 Launch { launched: bool },
36}
37
38#[derive(Debug, Clone, PartialEq)]
41pub struct ErrorText(artifactize_tools::result::ContentBlocks<Content>);
42
43impl ErrorText {
44 fn new(content: Vec<Content>) -> Result<Self, String> {
45 for block in &content {
46 let Content::Text { text } = block else {
47 return Err("Human tool error must contain only text blocks".into());
48 };
49 artifactize_tools::result::ErrorMessage::new(text.clone())
50 .map_err(|error| error.to_string())?;
51 }
52 artifactize_tools::result::ContentBlocks::new(content)
53 .map(Self)
54 .map_err(|error| error.to_string())
55 }
56}
57
58#[derive(Debug, Clone, PartialEq)]
59pub enum ToolResult {
60 Success(artifactize_tools::result::ContentBlocks<Content>),
61 Error(ErrorText),
62}
63
64impl ToolResult {
65 pub fn success(content: Content) -> Self {
66 Self::success_blocks(vec![content])
67 }
68
69 fn success_blocks(content: Vec<Content>) -> Self {
70 Self::Success(
71 artifactize_tools::result::ContentBlocks::new(content)
72 .expect("Human output always has one or two blocks"),
73 )
74 }
75
76 pub fn error(message: impl Into<String>) -> Self {
78 let message = artifactize_tools::result::ErrorMessage::diagnostic(message);
79 Self::error_blocks(vec![Content::Text {
80 text: message.as_str().to_owned(),
81 }])
82 }
83
84 fn error_blocks(content: Vec<Content>) -> Self {
85 Self::Error(ErrorText::new(content).expect("Human failures contain nonblank text"))
86 }
87
88 pub fn is_error(&self) -> bool {
89 matches!(self, Self::Error(_))
90 }
91
92 pub fn content(&self) -> &[Content] {
93 match self {
94 Self::Success(content) => content.as_slice(),
95 Self::Error(content) => content.0.as_slice(),
96 }
97 }
98}
99
100impl Serialize for ToolResult {
101 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
102 use serde::ser::SerializeStruct;
103 let mut result = serializer.serialize_struct("ToolResult", 2)?;
105 result.serialize_field("content", self.content())?;
106 result.serialize_field("isError", &self.is_error())?;
107 result.end()
108 }
109}
110
111impl<'de> Deserialize<'de> for ToolResult {
112 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
113 #[derive(Deserialize)]
114 #[serde(rename_all = "camelCase", deny_unknown_fields)]
115 struct SavedResult {
116 content: Vec<Content>,
117 is_error: bool,
118 }
119 let saved = SavedResult::deserialize(deserializer)?;
120 if saved.is_error {
121 ErrorText::new(saved.content)
122 .map(Self::Error)
123 .map_err(serde::de::Error::custom)
124 } else {
125 artifactize_tools::result::ContentBlocks::new(saved.content)
126 .map(Self::Success)
127 .map_err(serde::de::Error::custom)
128 }
129 }
130}
131
132#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct CommandLine {
135 pub repo: PathBuf,
136 pub kind: HumanToolKind,
137 pub program: OsString,
138 pub args: Vec<String>,
139 pub cwd: PathBuf,
140}
141
142struct RegisteredTool<'a> {
143 definition: ToolDefinition,
144 declaration: &'a HumanTool,
145}
146
147pub struct Registry<'a> {
149 config: &'a RepoConfig,
150 scope: Scope<'a>,
151 tools: BTreeMap<crate::config::ToolName, RegisteredTool<'a>>,
152}
153
154impl<'a> Registry<'a> {
155 pub fn new(config: &'a RepoConfig, eval_id: &str) -> Result<Self, String> {
156 let eval = config
157 .evals
158 .iter()
159 .find(|eval| eval.id == eval_id)
160 .ok_or_else(|| format!("Unknown eval: {eval_id}"))?;
161 if !matches!(eval.declaration.profile(), Profile::Human {}) {
162 return Err("Human tools require a Human eval.".into());
163 }
164 Self::with_scope(
165 config,
166 scope::eval_scope(config, eval).map_err(|e| e.to_string())?,
167 )
168 }
169
170 pub fn for_artifact(config: &'a RepoConfig, artifact_id: &str) -> Result<Self, String> {
171 let artifact_id: crate::config::ArtifactName = artifact_id.parse()?;
172 Self::with_scope(
173 config,
174 scope::artifact_scope(config, &[&artifact_id]).map_err(|e| e.to_string())?,
175 )
176 }
177
178 fn with_scope(config: &'a RepoConfig, scope: Scope<'a>) -> Result<Self, String> {
179 let mut tools = BTreeMap::new();
180 for (id, artifact) in &scope.artifacts {
181 for (operation, declaration) in &artifact.views.human_tools {
182 let name: crate::config::ToolName = format!("{operation}_{id}")
183 .parse()
184 .expect("validated tool and Artifact names");
185 let entry = RegisteredTool {
186 definition: ToolDefinition {
187 name: name.clone(),
188 artifact_id: artifact.name.clone(),
189 description: declaration.description().replace("{artifactName}", id),
190 kind: declaration.kind(),
191 },
192 declaration,
193 };
194 if tools.insert(name.clone(), entry).is_some() {
195 return Err(format!("Artifact tool names collide: {name}"));
196 }
197 }
198 }
199 Ok(Self {
200 config,
201 scope,
202 tools,
203 })
204 }
205
206 pub fn list(&self) -> impl Iterator<Item = &ToolDefinition> {
207 self.tools.values().map(|tool| &tool.definition)
208 }
209
210 pub fn is_command(&self, name: &crate::config::ToolName) -> bool {
211 self.tools
212 .get(name)
213 .is_some_and(|tool| matches!(tool.declaration, HumanTool::Command(_)))
214 }
215
216 pub fn preflight(&self, name: &crate::config::ToolName) -> Result<(), String> {
217 let tool = self
218 .tools
219 .get(name)
220 .ok_or("Unknown registered Human tool.")?;
221 let owner = &tool.definition.artifact_id;
222 self.scope
223 .resolve_input(&self.config.root, owner, "")
224 .map_err(|e| e.to_string())?;
225 match tool.declaration {
226 HumanTool::Command(declaration) => {
227 super::preflight_executable(
228 &self.config.root,
229 &self.scope,
230 owner,
231 &declaration.command,
232 )?;
233 scope::resolve_human_argv(self.config, &self.scope, owner, &declaration.args)
234 .map_err(|error| error.to_string())?;
235 }
236 HumanTool::Builtin(declaration) => {
237 scope::builtin_args(self.config, &self.scope, owner, declaration)
238 .map_err(|error| error.to_string())?;
239 }
240 }
241 Ok(())
242 }
243
244 pub fn command(&self, name: &crate::config::ToolName) -> Result<CommandLine, String> {
246 let tool = self
247 .tools
248 .get(name)
249 .ok_or("Unknown registered Human tool.")?;
250 let owner = &tool.definition.artifact_id;
251 let HumanTool::Command(declaration) = tool.declaration else {
252 return Err("Builtin Human tools have no executable command line.".into());
253 };
254 for artifact in self.scope.artifacts.values() {
255 scope::validate_file_target(&self.config.root, artifact)
256 .map_err(|error| error.to_string())?;
257 }
258 let cwd = scope::scoped_path(&self.config.root, self.config.artifacts[owner].folder())
259 .map_err(|e| e.to_string())?;
260 let program = executable(&self.config.root, &self.scope, owner, &declaration.command)
261 .map_err(|()| "Tool executable path is unavailable or outside scope.".to_owned())?;
262 let args = scope::resolve_human_argv(self.config, &self.scope, owner, &declaration.args)
263 .map_err(|e| e.to_string())?;
264 Ok(CommandLine {
265 repo: self.config.root.clone(),
266 kind: declaration.kind,
267 program,
268 args,
269 cwd,
270 })
271 }
272
273 pub async fn call(
274 &self,
275 name: &crate::config::ToolName,
276 cancellation: CancellationToken,
277 ) -> ToolResult {
278 self.call_logged(name, cancellation, Run::unwatched()).await
279 }
280
281 pub async fn call_logged(
284 &self,
285 name: &crate::config::ToolName,
286 cancellation: CancellationToken,
287 run: Run,
288 ) -> ToolResult {
289 let failed = |run: Run, message: String| {
290 run.end(End::Failed(message.clone()));
291 ToolResult::error(message)
292 };
293 let Some(tool) = self.tools.get(name) else {
294 return failed(run, "Unknown registered Human tool.".into());
295 };
296 if cancellation.is_cancelled() {
297 run.end(End::Cancelled);
298 return ToolResult::error("Human tool call was cancelled.");
299 }
300 let declaration = match tool.declaration {
301 HumanTool::Builtin(declaration) => {
302 return self
303 .call_builtin(&tool.definition.artifact_id, declaration, cancellation, run)
304 .await;
305 }
306 HumanTool::Command(declaration) => declaration,
307 };
308 let Ok(CommandLine {
309 program, args, cwd, ..
310 }) = self.command(name)
311 else {
312 return failed(run, "Human tool preparation failed.".into());
313 };
314 if cancellation.is_cancelled() {
315 run.end(End::Cancelled);
316 return ToolResult::error("Human tool call was cancelled.");
317 }
318 match declaration.kind {
319 HumanToolKind::Launch => match process::launch_watched(&program, &args, &cwd, run) {
320 Ok(()) => ToolResult::success(Content::Launch { launched: true }),
321 Err((error, run)) => failed(
322 run,
323 error
324 .argument_refusal()
325 .unwrap_or_else(|| "Human tool could not be launched.".into()),
326 ),
327 },
328 HumanToolKind::Output => {
329 let command = process::Command {
330 program,
331 args: args.into_iter().map(Into::into).collect(),
332 cwd,
333 env: crate::platform::environment::snapshot(),
334 timeout: declaration.timeout_ms.unwrap_or(DEFAULT_TIMEOUT),
335 };
336 match process::run_watched(command, cancellation, &run).await {
337 Ok(output) => {
338 run.end(End::exited(&output.status));
339 output_result(&output)
340 }
341 Err(process::Error::Timeout) => {
342 run.end(End::TimedOut);
343 ToolResult::error("Human tool timed out.")
344 }
345 Err(process::Error::Cancelled) => {
346 run.end(End::Cancelled);
347 ToolResult::error("Human tool call was cancelled.")
348 }
349 Err(error) => failed(
350 run,
351 error
352 .argument_refusal()
353 .unwrap_or_else(|| "Human tool execution failed.".into()),
354 ),
355 }
356 }
357 }
358 }
359
360 async fn call_builtin(
361 &self,
362 owner: &crate::config::ArtifactName,
363 declaration: &crate::config::HumanBuiltinTool,
364 cancellation: CancellationToken,
365 run: Run,
366 ) -> ToolResult {
367 let failed = |run: Run, message: String| {
368 run.end(End::Failed(message.clone()));
369 ToolResult::error(message)
370 };
371 for artifact in self.scope.artifacts.values() {
372 if let Err(error) = scope::validate_file_target(&self.config.root, artifact) {
373 return failed(run, error.to_string());
374 }
375 }
376 if declaration.builtin == crate::config::Builtin::Graph {
377 return match crate::graph_structure::launch(&self.config.root, run) {
379 Ok(()) => ToolResult::success(Content::Launch { launched: true }),
380 Err((error, run)) => failed(run, error),
381 };
382 }
383 let (owner, args) = match scope::builtin_args(self.config, &self.scope, owner, declaration)
384 {
385 Ok(args) => args,
386 Err(error) => return failed(run, error.to_string()),
387 };
388 let owner = scope::tool_id(&owner);
389 let tool_scope = self.scope.tool_scope();
390 if declaration.builtin == crate::config::Builtin::Open {
391 let target = if artifactize_tools::builtin::is_url(&args[0]) {
392 std::ffi::OsString::from(&args[0])
393 } else {
394 match tool_scope.resolve_input(&self.config.root, &owner, &args[0]) {
395 Ok(path) => path.into_os_string(),
396 Err(error) => return failed(run, error.to_string()),
397 }
398 };
399 return tokio::select! {
401 _ = cancellation.cancelled() => {
402 run.end(End::Cancelled);
403 ToolResult::error("Human tool call was cancelled.")
404 }
405 result = crate::platform::program::open_desktop(std::ffi::OsStr::new(&target)) => match result {
406 Ok(()) => {
407 run.end(End::Launched);
408 ToolResult::success(Content::Launch { launched: true })
409 }
410 Err(error) => failed(run, error.to_string()),
411 }
412 };
413 }
414 let launcher = process::Launcher {
417 env: crate::platform::environment::snapshot(),
418 };
419 let context = artifactize_tools::builtin::Context {
420 root: &self.config.root,
421 scope: &tool_scope,
422 owner: &owner,
423 launcher: &launcher,
424 };
425 let result = artifactize_tools::builtin::call_fixed(
426 declaration.builtin,
427 &args,
428 serde_json::json!({}),
429 context,
430 &cancellation,
431 )
432 .await;
433 let error = result.is_error();
434 let output = result
435 .into_content()
436 .into_iter()
437 .map(|content| match content {
438 artifactize_tools::Content::Text { text } => text,
439 artifactize_tools::Content::Json { data } => {
440 serde_json::to_string_pretty(&data).expect("builtin JSON")
441 }
442 artifactize_tools::Content::Image { .. } => {
443 unreachable!("Human builtins never produce images")
444 }
445 })
446 .collect::<Vec<_>>()
447 .join("\n");
448 let text = text(output.as_bytes(), String::new(), false);
449 if error {
450 run.text(Stream::Stderr, &text);
451 run.end(End::Failed("the builtin failed".into()));
452 ToolResult::error(text)
453 } else {
454 run.text(Stream::Stdout, &text);
455 run.end(End::Done);
456 ToolResult::success(Content::Text { text })
457 }
458 }
459}
460
461fn output_result(output: &process::Output) -> ToolResult {
462 let failed = !output.status.success();
463 let stdout = if failed {
464 format!(
465 "Human tool exited unsuccessfully ({}).\n",
466 crate::platform::exit_description(&output.status)
467 )
468 } else {
469 String::new()
470 };
471 let mut content = vec![Content::Text {
472 text: text(&output.stdout, stdout, output.truncated),
473 }];
474 if !output.stderr.is_empty() {
475 content.push(Content::Text {
476 text: text(&output.stderr, "stderr:\n".into(), output.truncated),
477 });
478 }
479 if failed {
480 ToolResult::error_blocks(content)
481 } else {
482 ToolResult::success_blocks(content)
483 }
484}
485
486fn text(bytes: &[u8], mut prefix: String, truncated: bool) -> String {
487 prefix
488 .push_str(&String::from_utf8(runtime::clean_output(bytes)).expect("clean output is UTF-8"));
489 if prefix.len() > TEXT_LIMIT || truncated {
490 let suffix = "\n[output truncated]";
491 let mut end = (TEXT_LIMIT - suffix.len()).min(prefix.len());
492 while !prefix.is_char_boundary(end) {
493 end -= 1;
494 }
495 prefix.truncate(end);
496 prefix.push_str(suffix);
497 }
498 prefix
499}