Skip to main content

artifactize_core/tools/
human.rs

1//! Predefined reviewer commands, deliberately outside Agent runtime isolation.
2
3pub 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
19/// Keep Human tool transcripts readable and bounded in CLI/TUI output; truncation is explicit.
20const 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/// Human failures retain separate stdout and stderr blocks in saved results,
39/// but cannot carry launch acknowledgements, empty content, or blank text.
40#[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    /// Blank diagnostics become "Tool failed."; nonblank text is unchanged.
77    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        // Human results historically include isError:false, unlike Agent results.
104        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/// What a registered tool runs: the repository, resolved program, argv and working directory.
133#[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
147/// Human catalog only. Claimant authorization belongs to the request lifecycle.
148pub 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    /// Resolve the command line a call would run, without running it.
245    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    /// Run tool `name`, its output reaching `run`'s Tool log while it runs, and end the run:
282    /// a launched program's when it exits.
283    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            // The graph viewer's own output is logged until it exits.
378            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            // The desktop's opener takes the file; what it prints is not the tool's.
400            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        // A Human tool runs as the reviewer, with the reviewer's environment, as output
415        // tools do.
416        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}