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