Skip to main content

artifactize_tools/builtin/
fixed.rs

1//! Fixed declarations shared by the Agent/Human registries and standalone CLI.
2use std::{
3    ffi::{OsStr, OsString},
4    io::Read,
5    path::Path,
6    time::Duration,
7};
8
9use pulldown_cmark::{Event, Parser, Tag, TagEnd};
10use serde::Deserialize;
11use serde_json::{Value, json};
12use tokio_util::sync::CancellationToken;
13
14use crate::{
15    Builtin, Content, ToolResult,
16    launch::{Launch, LaunchError, Launcher},
17    program,
18    scope::{self, ArtifactId, Scope},
19};
20
21/// Fixed text tools contribute at most one 64 KiB block to a review.
22const TEXT_BYTES: usize = 64 * 1024;
23/// Bound Markdown parsing, including a section near the end of the document.
24const DOCUMENT_BYTES: usize = 8 * 1024 * 1024;
25/// Help is documentation, not a long-running owner command.
26const HELP_TIMEOUT: Duration = Duration::from_secs(10);
27
28pub fn validate_args(builtin: Builtin, args: Option<&[String]>, human: bool) -> Result<(), String> {
29    let Some(args) = args else {
30        return if !human
31            && matches!(
32                builtin,
33                Builtin::Read | Builtin::List | Builtin::Glob | Builtin::Grep | Builtin::ViewImage
34            ) {
35            Ok(())
36        } else {
37            Err("This builtin requires fixed args.".into())
38        };
39    };
40    let valid = match builtin {
41        Builtin::Graph => args.is_empty(),
42        Builtin::Read | Builtin::List => args.len() == 1,
43        Builtin::Section => args.len() == 2 || !human && args.len() == 1,
44        Builtin::Help => !args.is_empty(),
45        Builtin::Open => human && args.len() == 1,
46        Builtin::Glob | Builtin::Grep | Builtin::ViewImage => false,
47    };
48    if !valid
49        || args.iter().any(|arg| arg.contains('\0'))
50        || builtin == Builtin::Help && args.first().is_some_and(|arg| arg.trim().is_empty())
51    {
52        return Err("Invalid fixed args for this builtin/kind.".into());
53    }
54    if builtin == Builtin::Section && args.get(1).is_some_and(|heading| heading.trim().is_empty()) {
55        return Err("Section heading must not be blank.".into());
56    }
57    Ok(())
58}
59
60pub fn fixed_description(builtin: Builtin, args: &[String]) -> &'static str {
61    match builtin {
62        Builtin::Read => {
63            "Read the declared UTF-8 file in {artifactName}, up to 64 KiB. No input arguments; no symlinks or binary data."
64        }
65        Builtin::List => {
66            "List the declared directory in {artifactName}. No input arguments; returns sorted entries and truncation metadata."
67        }
68        Builtin::Section if args.len() == 1 => {
69            "Read the declared Markdown file in {artifactName} by exact heading. Supply only heading; includes nested sections until the next same or higher level heading. Missing headings list available titles."
70        }
71        Builtin::Section => {
72            "Read the declared Markdown section in {artifactName}. No input arguments; includes nested sections until the next same or higher level heading."
73        }
74        Builtin::Help => {
75            "Read the declared program/subcommand --help output. No input arguments; limited to 10 seconds and 64 KiB."
76        }
77        _ => super::description(builtin),
78    }
79}
80
81pub fn fixed_schema(builtin: Builtin, args: &[String]) -> Value {
82    if builtin == Builtin::Section && args.len() == 1 {
83        super::input_schema(builtin)
84    } else {
85        json!({"type":"object", "properties":{}, "required":[], "additionalProperties":false})
86    }
87}
88
89/// Only explicit HTTP(S) URLs bypass filesystem scope; local file URLs do not.
90pub fn is_url(target: &str) -> bool {
91    target.starts_with("https://") || target.starts_with("http://")
92}
93
94pub fn validate_target(
95    builtin: Builtin,
96    args: &[String],
97    root: &Path,
98    scope: &Scope,
99    owner: &ArtifactId,
100) -> Result<(), String> {
101    if builtin == Builtin::Graph
102        || builtin == Builtin::Help
103        || builtin == Builtin::Open && is_url(&args[0])
104    {
105        return Ok(());
106    }
107    // Existing targets are validated without following any symlink.
108    let location = scope
109        .resolve_path(owner, &args[0])
110        .map_err(|error| error.to_string())?;
111    let artifact = &scope.artifacts[&location.artifact_id];
112    let base = scope::scoped_path(root, artifact.folder()).map_err(|error| error.to_string())?;
113    let path = if location.path.is_empty() {
114        artifact.file_name().unwrap_or("")
115    } else {
116        &location.path
117    };
118    scope::scoped_path(&base, Path::new(path))
119        .map(|_| ())
120        .map_err(|error| error.to_string())
121}
122
123/// Where a fixed built-in runs: the workspace root, the scope it may read, the Artifact
124/// that declares it, and the launcher that starts a `help` program.
125pub struct Context<'a> {
126    pub root: &'a Path,
127    pub scope: &'a Scope,
128    pub owner: &'a ArtifactId,
129    pub launcher: &'a dyn Launcher,
130}
131
132pub async fn call_fixed(
133    builtin: Builtin,
134    args: &[String],
135    input: Value,
136    context: Context<'_>,
137    cancellation: &CancellationToken,
138) -> ToolResult {
139    if cancellation.is_cancelled() {
140        return ToolResult::error("Tool call was cancelled.");
141    }
142    let input = match FixedInput::parse(builtin, args, input) {
143        Ok(input) => input,
144        Err(error) => return ToolResult::error(error),
145    };
146    let Context {
147        root,
148        scope,
149        owner,
150        launcher,
151    } = context;
152    if let FixedInput::Help(args) = input {
153        let cwd = root.join(scope.artifacts[owner].folder());
154        return match help(&args, &cwd, launcher, cancellation).await {
155            Ok(text) => text_result(text),
156            Err(error) => ToolResult::error(bounded(&error)),
157        };
158    }
159    let root = root.to_owned();
160    let scope = Scope {
161        artifacts: scope.artifacts.clone(),
162    };
163    let owner = owner.clone();
164    let cancellation = cancellation.child_token();
165    let _cancel_on_drop = cancellation.clone().drop_guard();
166    tokio::task::spawn_blocking(move || call_text(input, &root, &scope, &owner, &cancellation))
167        .await
168        .unwrap_or_else(|error| {
169            if error.is_panic() {
170                std::panic::resume_unwind(error.into_panic());
171            }
172            ToolResult::error("Built-in tool execution failed.")
173        })
174}
175
176/// The model's input to a fixed `section` tool: the heading, when the declaration leaves
177/// it open.
178#[derive(Deserialize)]
179#[serde(deny_unknown_fields)]
180struct SectionInput {
181    heading: Option<String>,
182}
183
184/// Parsed fixed declarations and model input. Execution cannot index an absent argument
185/// or combine a heading with a tool that does not accept one.
186enum FixedInput {
187    Read { path: String },
188    List(super::input::List),
189    Section { path: String, heading: String },
190    Help(Vec<String>),
191}
192
193impl FixedInput {
194    fn parse(builtin: Builtin, args: &[String], value: Value) -> Result<Self, String> {
195        validate_args(builtin, Some(args), false)?;
196        let validator = crate::schema::compile(&fixed_schema(builtin, args))?;
197        crate::schema::validate(&validator, &value)?;
198        match builtin {
199            Builtin::Read => Ok(Self::Read {
200                path: args[0].clone(),
201            }),
202            Builtin::List => Ok(Self::List(super::input::List {
203                path: args[0].clone(),
204                offset: 0,
205                limit: super::MAX_RESULTS,
206            })),
207            Builtin::Section => {
208                let input: SectionInput = serde_json::from_value(value)
209                    .map_err(|_| "Tool arguments cannot be read as the declared builtin input.")?;
210                let heading = args
211                    .get(1)
212                    .cloned()
213                    .or(input.heading)
214                    .ok_or("Section requires heading.")?;
215                Ok(Self::Section {
216                    path: args[0].clone(),
217                    heading,
218                })
219            }
220            Builtin::Help => Ok(Self::Help(args.to_vec())),
221            _ => Err("This builtin cannot run as a fixed Agent tool.".into()),
222        }
223    }
224}
225
226fn call_text(
227    input: FixedInput,
228    root: &Path,
229    scope: &Scope,
230    owner: &ArtifactId,
231    cancellation: &CancellationToken,
232) -> ToolResult {
233    let result = match input {
234        FixedInput::Read { path } => {
235            read_text(root, scope, owner, &path, TEXT_BYTES).map(|text| bounded(&text))
236        }
237        FixedInput::Section { path, heading } => {
238            read_text(root, scope, owner, &path, DOCUMENT_BYTES)
239                .and_then(|text| section(&text, &heading))
240        }
241        FixedInput::List(input) => {
242            return super::call(super::Input::List(input), root, scope, owner, cancellation);
243        }
244        FixedInput::Help(_) => unreachable!("help executes asynchronously at the process edge"),
245    };
246    if cancellation.is_cancelled() {
247        return ToolResult::error("Tool call was cancelled.");
248    }
249    match result {
250        Ok(text) => text_result(text),
251        Err(error) => ToolResult::error(error),
252    }
253}
254
255fn text_result(text: String) -> ToolResult {
256    ToolResult::success(Content::Text { text })
257}
258
259fn read_text(
260    root: &Path,
261    scope: &Scope,
262    owner: &ArtifactId,
263    path: &str,
264    limit: usize,
265) -> Result<String, String> {
266    let location = scope
267        .resolve_path(owner, path)
268        .map_err(|error| error.to_string())?;
269    let artifact = &scope.artifacts[&location.artifact_id];
270    let path = if location.path.is_empty() {
271        artifact.file_name().unwrap_or("")
272    } else {
273        &location.path
274    };
275    let file = scope::open_input(root, artifact, path).map_err(|error| error.to_string())?;
276    if !file
277        .metadata()
278        .map_err(|error| error.to_string())?
279        .is_file()
280    {
281        return Err("Reading requires a regular file.".into());
282    }
283    let mut bytes = Vec::new();
284    file.take((limit + 1) as u64)
285        .read_to_end(&mut bytes)
286        .map_err(|error| error.to_string())?;
287    if bytes.contains(&0) {
288        return Err("Reading requires UTF-8 text, not binary data.".into());
289    }
290    if bytes.len() > limit && limit == DOCUMENT_BYTES {
291        return Err("Markdown document exceeds 8 MiB.".into());
292    }
293    if bytes.len() > limit {
294        // Only an incomplete trailing UTF-8 sequence may be omitted at the byte boundary.
295        if let Err(error) = std::str::from_utf8(&bytes) {
296            if error.error_len().is_some() {
297                return Err("Reading requires UTF-8 text.".into());
298            }
299            bytes.truncate(error.valid_up_to());
300        }
301        let text =
302            String::from_utf8(bytes).map_err(|_| "Reading requires UTF-8 text.".to_owned())?;
303        return Ok(bounded(&format!("{text}\n[output truncated]")));
304    }
305    String::from_utf8(bytes).map_err(|_| "Reading requires UTF-8 text.".into())
306}
307
308fn bounded(text: &str) -> String {
309    if text.len() <= TEXT_BYTES {
310        return text.to_owned();
311    }
312    let suffix = "\n[output truncated]";
313    let mut end = TEXT_BYTES - suffix.len();
314    while !text.is_char_boundary(end) {
315        end -= 1;
316    }
317    format!("{}{suffix}", &text[..end])
318}
319
320fn section(text: &str, wanted: &str) -> Result<String, String> {
321    let mut headings = Vec::new();
322    let mut heading = None;
323    for (event, range) in Parser::new(text).into_offset_iter() {
324        match event {
325            Event::Start(Tag::Heading { level, .. }) => {
326                heading = Some((level, range.start, String::new()))
327            }
328            Event::Text(value) | Event::Code(value) => {
329                if let Some((_, _, title)) = &mut heading {
330                    title.push_str(&value);
331                }
332            }
333            Event::SoftBreak | Event::HardBreak => {
334                if let Some((_, _, title)) = &mut heading {
335                    title.push(' ');
336                }
337            }
338            Event::End(TagEnd::Heading(_)) => {
339                if let Some(heading) = heading.take() {
340                    headings.push(heading);
341                }
342            }
343            _ => {}
344        }
345    }
346    let Some(index) = headings.iter().position(|(_, _, title)| title == wanted) else {
347        return Err(bounded(&format!(
348            "No matching heading: {wanted}. Available headings:\n{}",
349            headings
350                .iter()
351                .map(|(_, _, title)| title.as_str())
352                .collect::<Vec<_>>()
353                .join("\n")
354        )));
355    };
356    let (level, start, _) = &headings[index];
357    let end = headings[index + 1..]
358        .iter()
359        .find(|(next, _, _)| next <= level)
360        .map_or(text.len(), |(_, start, _)| *start);
361    Ok(bounded(&text[*start..end]))
362}
363
364async fn help(
365    args: &[String],
366    cwd: &Path,
367    launcher: &dyn Launcher,
368    cancellation: &CancellationToken,
369) -> Result<String, String> {
370    let program = program::resolve(OsStr::new(&args[0]), cwd).map_err(|error| error.to_string())?;
371    let launch = Launch {
372        program,
373        args: args[1..]
374            .iter()
375            .map(OsString::from)
376            .chain([OsString::from("--help")])
377            .collect(),
378        cwd: cwd.to_owned(),
379        timeout: HELP_TIMEOUT,
380        output_limit: TEXT_BYTES,
381    };
382    let finished = launcher
383        .run(launch, cancellation)
384        .await
385        .map_err(|error| match error {
386            LaunchError::TimedOut => "Help timed out.".to_owned(),
387            error => error.to_string(),
388        })?;
389    if !finished.status.success() {
390        return Err(format!(
391            "Help exited unsuccessfully ({}).\n{}",
392            crate::platform::exit_description(&finished.status),
393            String::from_utf8_lossy(&finished.stderr)
394        ));
395    }
396    let mut bytes = finished.stdout;
397    bytes.extend(finished.stderr);
398    if finished.truncated || bytes.len() > TEXT_BYTES {
399        return Err("Help output exceeds 64 KiB.".into());
400    }
401    Ok(String::from_utf8(crate::result::clean_output(&bytes)).expect("clean output is UTF-8"))
402}