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::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
88pub 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 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
119pub 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#[derive(Deserialize)]
175#[serde(deny_unknown_fields)]
176struct SectionInput {
177 heading: Option<String>,
178}
179
180enum 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 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}