Skip to main content

artifactize_core/tools/
human.rs

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