1use 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
16const 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#[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 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 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#[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
144pub 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 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 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}