1use 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
21const TEXT_BYTES: usize = 64 * 1024;
23const DOCUMENT_BYTES: usize = 8 * 1024 * 1024;
25const 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
89pub 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 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
123pub 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#[derive(Deserialize)]
179#[serde(deny_unknown_fields)]
180struct SectionInput {
181 heading: Option<String>,
182}
183
184enum 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 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}