artifactize_tools/builtin/
fixed.rs1use 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;
17const TEXT_BYTES: usize = 64 * 1024;
19const DOCUMENT_BYTES: usize = 8 * 1024 * 1024;
21
22pub 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#[derive(Deserialize)]
78#[serde(deny_unknown_fields)]
79struct SectionInput {
80 heading: Option<String>,
81}
82
83enum 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 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}