Skip to main content

artifactize_tools/builtin/
fixed.rs

1//! Fixed tool parsing and scoped text execution shared by every caller.
2use crate::{
3    Builtin, Content, ToolResult,
4    launch::Launcher,
5    scope::{self, ArtifactId, Scope},
6};
7use serde::Deserialize;
8use serde_json::Value;
9use std::{io::Read, path::Path};
10use tokio_util::sync::CancellationToken;
11mod declaration;
12mod help;
13mod markdown;
14pub use declaration::{fixed_description, fixed_schema, is_url, validate_args, validate_target};
15use help::help;
16use markdown::section;
17/// Fixed text tools contribute at most one 64 KiB block to a review.
18const TEXT_BYTES: usize = 64 * 1024;
19/// Bound Markdown parsing, including a section near the end of the document.
20const DOCUMENT_BYTES: usize = 8 * 1024 * 1024;
21
22/// Where a fixed built-in runs: the workspace root, the scope it may read, the Artifact
23/// that declares it, and the launcher that starts a `help` program.
24pub struct Context<'a> {
25    pub root: &'a Path,
26    pub scope: &'a Scope,
27    pub owner: &'a ArtifactId,
28    pub launcher: &'a dyn Launcher,
29}
30
31pub async fn call_fixed(
32    builtin: Builtin,
33    args: &[String],
34    input: Value,
35    context: Context<'_>,
36    cancellation: &CancellationToken,
37) -> ToolResult {
38    if cancellation.is_cancelled() {
39        return ToolResult::error("Tool call was cancelled.");
40    }
41    let input = match FixedInput::parse(builtin, args, input) {
42        Ok(input) => input,
43        Err(error) => return ToolResult::error(error),
44    };
45    let Context {
46        root,
47        scope,
48        owner,
49        launcher,
50    } = context;
51    if let FixedInput::Help(args) = input {
52        let cwd = root.join(scope.artifacts[owner].folder());
53        return match help(&args, &cwd, launcher, cancellation).await {
54            Ok(text) => text_result(text),
55            Err(error) => ToolResult::error(bounded(&error)),
56        };
57    }
58    let root = root.to_owned();
59    let scope = Scope {
60        artifacts: scope.artifacts.clone(),
61    };
62    let owner = owner.clone();
63    let cancellation = cancellation.child_token();
64    let _cancel_on_drop = cancellation.clone().drop_guard();
65    tokio::task::spawn_blocking(move || call_text(input, &root, &scope, &owner, &cancellation))
66        .await
67        .unwrap_or_else(|error| {
68            if error.is_panic() {
69                std::panic::resume_unwind(error.into_panic());
70            }
71            ToolResult::error("Built-in tool execution failed.")
72        })
73}
74
75/// The model's input to a fixed `section` tool: the heading, when the declaration leaves
76/// it open.
77#[derive(Deserialize)]
78#[serde(deny_unknown_fields)]
79struct SectionInput {
80    heading: Option<String>,
81}
82
83/// Parsed fixed declarations and model input. Execution cannot index an absent argument
84/// or combine a heading with a tool that does not accept one.
85enum FixedInput {
86    Read {
87        path: super::input::LogicalPath,
88    },
89    List(super::input::List),
90    Section {
91        path: super::input::LogicalPath,
92        heading: String,
93    },
94    Help(Vec<String>),
95}
96
97impl FixedInput {
98    fn parse(builtin: Builtin, args: &[String], value: Value) -> Result<Self, String> {
99        validate_args(builtin, Some(args), false)?;
100        let validator = crate::schema::compile(&fixed_schema(builtin, args))?;
101        crate::schema::validate(&validator, &value)?;
102        match builtin {
103            Builtin::Read => Ok(Self::Read {
104                path: super::input::LogicalPath::parse(args[0].clone())?,
105            }),
106            Builtin::List => Ok(Self::List(super::input::List {
107                path: super::input::LogicalPath::parse(args[0].clone())?,
108                offset: 0,
109                limit: super::MAX_RESULTS,
110            })),
111            Builtin::Section => {
112                let input: SectionInput = serde_json::from_value(value)
113                    .map_err(|_| "Tool arguments cannot be read as the declared builtin input.")?;
114                let heading = args
115                    .get(1)
116                    .cloned()
117                    .or(input.heading)
118                    .ok_or("Section requires heading.")?;
119                Ok(Self::Section {
120                    path: super::input::LogicalPath::parse(args[0].clone())?,
121                    heading,
122                })
123            }
124            Builtin::Help => Ok(Self::Help(args.to_vec())),
125            _ => Err("This builtin cannot run as a fixed Agent tool.".into()),
126        }
127    }
128}
129
130fn call_text(
131    input: FixedInput,
132    root: &Path,
133    scope: &Scope,
134    owner: &ArtifactId,
135    cancellation: &CancellationToken,
136) -> ToolResult {
137    let result = match input {
138        FixedInput::Read { path } => {
139            read_text(root, scope, owner, &path, TEXT_BYTES).map(|text| bounded(&text))
140        }
141        FixedInput::Section { path, heading } => {
142            read_text(root, scope, owner, &path, DOCUMENT_BYTES)
143                .and_then(|text| section(&text, &heading))
144        }
145        FixedInput::List(input) => {
146            return super::call(super::Input::List(input), root, scope, owner, cancellation);
147        }
148        FixedInput::Help(_) => unreachable!("help executes asynchronously at the process edge"),
149    };
150    if cancellation.is_cancelled() {
151        return ToolResult::error("Tool call was cancelled.");
152    }
153    match result {
154        Ok(text) => text_result(text),
155        Err(error) => ToolResult::error(error),
156    }
157}
158
159fn text_result(text: String) -> ToolResult {
160    ToolResult::success(Content::Text { text })
161}
162
163fn read_text(
164    root: &Path,
165    scope: &Scope,
166    owner: &ArtifactId,
167    path: &str,
168    limit: usize,
169) -> Result<String, String> {
170    let location = scope
171        .resolve_path(owner, path)
172        .map_err(|error| error.to_string())?;
173    let artifact = &scope.artifacts[&location.artifact_id];
174    let path = if location.path.is_empty() {
175        artifact.file_name().unwrap_or("")
176    } else {
177        &location.path
178    };
179    let file = scope::open_input(root, artifact, path).map_err(|error| error.to_string())?;
180    if !file
181        .metadata()
182        .map_err(|error| error.to_string())?
183        .is_file()
184    {
185        return Err("Reading requires a regular file.".into());
186    }
187    let mut bytes = Vec::new();
188    file.take((limit + 1) as u64)
189        .read_to_end(&mut bytes)
190        .map_err(|error| error.to_string())?;
191    if bytes.contains(&0) {
192        return Err("Reading requires UTF-8 text, not binary data.".into());
193    }
194    if bytes.len() > limit && limit == DOCUMENT_BYTES {
195        return Err("Markdown document exceeds 8 MiB.".into());
196    }
197    if bytes.len() > limit {
198        // Only an incomplete trailing UTF-8 sequence may be omitted at the byte boundary.
199        if let Err(error) = std::str::from_utf8(&bytes) {
200            if error.error_len().is_some() {
201                return Err("Reading requires UTF-8 text.".into());
202            }
203            bytes.truncate(error.valid_up_to());
204        }
205        let text =
206            String::from_utf8(bytes).map_err(|_| "Reading requires UTF-8 text.".to_owned())?;
207        return Ok(bounded(&format!("{text}\n[output truncated]")));
208    }
209    String::from_utf8(bytes).map_err(|_| "Reading requires UTF-8 text.".into())
210}
211
212fn bounded(text: &str) -> String {
213    if text.len() <= TEXT_BYTES {
214        return text.to_owned();
215    }
216    let suffix = "\n[output truncated]";
217    let mut end = TEXT_BYTES - suffix.len();
218    while !text.is_char_boundary(end) {
219        end -= 1;
220    }
221    format!("{}{suffix}", &text[..end])
222}