1use std::fmt;
2use std::num::NonZeroU64;
3
4use serde::{Deserialize, Deserializer, Serialize, de};
5use serde_json::{Number, Value, json};
6
7const READ_DESCRIPTION: &str =
8 "Read a repository-relative file, optionally selecting a line range.";
9const READ_NAME: &str = "read";
10
11#[derive(Clone, Copy, Debug, Eq, PartialEq)]
13pub enum Tool {
14 Read,
16}
17
18#[derive(Clone, Debug, Eq, PartialEq)]
23pub struct ToolDefinition {
24 description: &'static str,
25 name: &'static str,
26 parameters: Value,
27}
28
29impl ToolDefinition {
30 pub fn read() -> Self {
32 Self {
33 description: READ_DESCRIPTION,
34 name: READ_NAME,
35 parameters: json!({
36 "type": "object",
37 "properties": {
38 "path": {
39 "type": "string",
40 "minLength": 1,
41 "pattern": "^(?:[^./\\\\\\u0000][^/\\\\\\u0000]*|\\.[^./\\\\\\u0000][^/\\\\\\u0000]*|\\.\\.[^/\\\\\\u0000]+)(?:/(?:[^./\\\\\\u0000][^/\\\\\\u0000]*|\\.[^./\\\\\\u0000][^/\\\\\\u0000]*|\\.\\.[^/\\\\\\u0000]+))*$"
42 },
43 "offset": {
44 "type": "integer",
45 "minimum": 1,
46 "maximum": u64::MAX
47 },
48 "limit": {
49 "type": "integer",
50 "minimum": 1,
51 "maximum": u64::MAX
52 }
53 },
54 "required": ["path"],
55 "additionalProperties": false
56 }),
57 }
58 }
59
60 pub fn description(&self) -> &'static str {
62 self.description
63 }
64
65 pub fn name(&self) -> &'static str {
67 self.name
68 }
69
70 pub fn parameters(&self) -> &Value {
72 &self.parameters
73 }
74}
75
76#[derive(Clone, Eq, PartialEq)]
78pub struct ToolCall {
79 arguments: ReadArguments,
80 id: String,
81 name: String,
82 reasoning_content: Option<String>,
83}
84
85impl fmt::Debug for ToolCall {
86 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
87 formatter
88 .debug_struct("ToolCall")
89 .field("arguments", &self.arguments)
90 .field("id", &self.id)
91 .field("name", &self.name)
92 .field(
93 "reasoning_content",
94 &self.reasoning_content.as_ref().map(|_| "[REDACTED]"),
95 )
96 .finish()
97 }
98}
99
100impl ToolCall {
101 pub fn arguments(&self) -> &ReadArguments {
103 &self.arguments
104 }
105
106 pub fn id(&self) -> &str {
108 &self.id
109 }
110
111 pub fn name(&self) -> &str {
113 &self.name
114 }
115
116 pub(crate) fn read(
117 id: String,
118 arguments: ReadArguments,
119 reasoning_content: Option<String>,
120 ) -> Self {
121 Self {
122 arguments,
123 id,
124 name: READ_NAME.to_string(),
125 reasoning_content,
126 }
127 }
128
129 pub(crate) fn arguments_json(&self) -> Result<String, serde_json::Error> {
130 serde_json::to_string(&self.arguments)
131 }
132
133 pub(crate) fn reasoning_content(&self) -> Option<&str> {
134 self.reasoning_content.as_deref()
135 }
136}
137
138#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
144#[serde(deny_unknown_fields)]
145pub struct ReadArguments {
146 #[serde(
147 default,
148 deserialize_with = "deserialize_optional_positive_integer",
149 skip_serializing_if = "Option::is_none"
150 )]
151 limit: Option<NonZeroU64>,
152 #[serde(
153 default,
154 deserialize_with = "deserialize_optional_positive_integer",
155 skip_serializing_if = "Option::is_none"
156 )]
157 offset: Option<NonZeroU64>,
158 #[serde(deserialize_with = "deserialize_repository_path")]
159 path: String,
160}
161
162impl ReadArguments {
163 pub fn limit(&self) -> Option<u64> {
165 self.limit.map(NonZeroU64::get)
166 }
167
168 pub fn offset(&self) -> Option<u64> {
170 self.offset.map(NonZeroU64::get)
171 }
172
173 pub fn path(&self) -> &str {
175 &self.path
176 }
177}
178
179fn deserialize_optional_positive_integer<'de, D>(
180 deserializer: D,
181) -> Result<Option<NonZeroU64>, D::Error>
182where
183 D: Deserializer<'de>,
184{
185 let number = Number::deserialize(deserializer)?;
186 parse_positive_json_integer(&number.to_string())
187 .and_then(NonZeroU64::new)
188 .map(Some)
189 .ok_or_else(|| de::Error::custom("number must be an integer from 1 through u64::MAX"))
190}
191
192fn parse_positive_json_integer(number: &str) -> Option<u64> {
193 let (mantissa, exponent) = match number.split_once(['e', 'E']) {
194 Some((mantissa, exponent)) => (mantissa, exponent.parse::<i64>().ok()?),
195 None => (number, 0),
196 };
197 if mantissa.starts_with('-') {
198 return None;
199 }
200 let (whole, fraction) = mantissa.split_once('.').unwrap_or((mantissa, ""));
201 let mut digits = String::with_capacity(whole.len() + fraction.len());
202 digits.push_str(whole);
203 digits.push_str(fraction);
204 let scale = exponent.checked_sub(i64::try_from(fraction.len()).ok()?)?;
205 let appended_zeros = if scale < 0 {
206 let removed_digits = usize::try_from(scale.unsigned_abs()).ok()?;
207 if removed_digits >= digits.len()
208 || !digits[digits.len() - removed_digits..]
209 .bytes()
210 .all(|digit| digit == b'0')
211 {
212 return None;
213 }
214 digits.truncate(digits.len() - removed_digits);
215
216 0
217 } else {
218 usize::try_from(scale).ok()?
219 };
220 let digits = digits.trim_start_matches('0');
221 if digits.is_empty() || digits.len().checked_add(appended_zeros)? > 20 {
222 return None;
223 }
224 let value = digits.parse::<u64>().ok()?;
225
226 (0..appended_zeros).try_fold(value, |value, _| value.checked_mul(10))
227}
228
229fn deserialize_repository_path<'de, D>(deserializer: D) -> Result<String, D::Error>
230where
231 D: Deserializer<'de>,
232{
233 let path = String::deserialize(deserializer)?;
234 if path.is_empty() {
235 return Err(de::Error::custom("path must not be empty"));
236 }
237 if path.starts_with('/') || path.contains('\\') {
238 return Err(de::Error::custom("path must be repository-relative"));
239 }
240 if path.contains('\0') {
241 return Err(de::Error::custom("path must not contain NUL"));
242 }
243 if path
244 .split('/')
245 .any(|component| component.is_empty() || matches!(component, "." | ".."))
246 {
247 return Err(de::Error::custom(
248 "path must not contain empty, current-directory, or parent-directory components",
249 ));
250 }
251
252 Ok(path)
253}
254
255#[cfg(test)]
256mod tests {
257 use jsonschema::Validator;
258 use serde_json::json;
259
260 use super::*;
261
262 #[test]
263 fn read_definition_exposes_native_function_contract() {
264 let definition = ToolDefinition::read();
266 let validator =
267 Validator::new(definition.parameters()).expect("read argument schema should compile");
268
269 assert_eq!(definition.name(), "read");
271 assert_eq!(
272 definition.description(),
273 "Read a repository-relative file, optionally selecting a line range."
274 );
275 assert!(validator.is_valid(&json!({ "path": "Cargo.toml" })));
276 assert!(validator.is_valid(&json!({
277 "path": "crates/ag-harness/src/lib.rs",
278 "offset": 1,
279 "limit": 12
280 })));
281 assert!(validator.is_valid(&json!({
282 "path": "Cargo.toml",
283 "offset": u64::MAX,
284 "limit": u64::MAX
285 })));
286 assert!(validator.is_valid(&json!({
287 "path": "Cargo.toml",
288 "offset": 1.0,
289 "limit": 1e0
290 })));
291 }
292
293 #[test]
294 fn read_definition_rejects_invalid_arguments() {
295 let definition = ToolDefinition::read();
297 let validator =
298 Validator::new(definition.parameters()).expect("read argument schema should compile");
299 let offset_above_maximum =
300 serde_json::from_str(r#"{"path":"Cargo.toml","offset":18446744073709551616}"#)
301 .expect("out-of-range offset fixture should be valid JSON");
302 let limit_above_maximum =
303 serde_json::from_str(r#"{"path":"Cargo.toml","limit":18446744073709551616}"#)
304 .expect("out-of-range limit fixture should be valid JSON");
305 let invalid_arguments = [
306 json!({}),
307 json!({ "path": "" }),
308 json!({ "path": "/Cargo.toml" }),
309 json!({ "path": "C:\\Cargo.toml" }),
310 json!({ "path": "../Cargo.toml" }),
311 json!({ "path": "Cargo\0.toml" }),
312 json!({ "path": "Cargo.toml", "offset": 0 }),
313 json!({ "path": "Cargo.toml", "limit": 0 }),
314 json!({ "path": "Cargo.toml", "offset": null }),
315 json!({ "path": "Cargo.toml", "limit": null }),
316 json!({ "path": "Cargo.toml", "unexpected": true }),
317 offset_above_maximum,
318 limit_above_maximum,
319 ];
320
321 let results = invalid_arguments.map(|arguments| validator.is_valid(&arguments));
323
324 assert!(results.into_iter().all(|is_valid| !is_valid));
326 }
327
328 #[test]
329 fn read_arguments_reject_invalid_repository_paths() {
330 let invalid_paths = [
332 "",
333 "/Cargo.toml",
334 "C:\\Cargo.toml",
335 "server\\share",
336 "src//lib.rs",
337 "src/./lib.rs",
338 "../lib.rs",
339 "Cargo\0.toml",
340 ];
341
342 let errors = invalid_paths.map(|path| {
344 serde_json::from_value::<ReadArguments>(json!({ "path": path }))
345 .expect_err("invalid path should be rejected")
346 });
347
348 assert!(
350 errors
351 .into_iter()
352 .all(|error| !error.to_string().is_empty())
353 );
354 }
355
356 #[test]
357 fn read_arguments_distinguish_missing_ranges_from_null() {
358 let omitted = json!({ "path": "Cargo.toml" });
360 let explicit_null = [
361 json!({ "path": "Cargo.toml", "offset": null }),
362 json!({ "path": "Cargo.toml", "limit": null }),
363 ];
364
365 let arguments = serde_json::from_value::<ReadArguments>(omitted)
367 .expect("omitted ranges should remain optional");
368 let errors = explicit_null.map(|value| {
369 serde_json::from_value::<ReadArguments>(value)
370 .expect_err("explicit null range should be rejected")
371 });
372
373 assert_eq!(arguments.offset(), None);
375 assert_eq!(arguments.limit(), None);
376 assert!(
377 errors
378 .into_iter()
379 .all(|error| !error.to_string().is_empty())
380 );
381 }
382
383 #[test]
384 fn read_arguments_accept_maximum_ranges() {
385 let value = json!({
387 "path": "Cargo.toml",
388 "offset": u64::MAX,
389 "limit": u64::MAX
390 });
391
392 let arguments = serde_json::from_value::<ReadArguments>(value)
394 .expect("maximum u64 ranges should decode");
395
396 assert_eq!(arguments.offset(), Some(u64::MAX));
398 assert_eq!(arguments.limit(), Some(u64::MAX));
399 }
400
401 #[test]
402 fn read_arguments_accept_integral_decimal_and_exponent_ranges() {
403 let values = [
405 (r#"{"path":"Cargo.toml","offset":1.0,"limit":1e0}"#, (1, 1)),
406 (
407 r#"{"path":"Cargo.toml","offset":1e2,"limit":100e-2}"#,
408 (100, 1),
409 ),
410 (
411 r#"{"path":"Cargo.toml","offset":18446744073709551615.0,"limit":18446744073709551615e0}"#,
412 (u64::MAX, u64::MAX),
413 ),
414 ];
415
416 let arguments = values.map(|(value, expected)| {
418 serde_json::from_str::<ReadArguments>(value)
419 .map(|arguments| (arguments, expected))
420 .expect("integral numeric forms should decode")
421 });
422
423 assert!(arguments.iter().all(|(arguments, expected)| {
425 arguments.offset() == Some(expected.0) && arguments.limit() == Some(expected.1)
426 }));
427 }
428
429 #[test]
430 fn read_arguments_reject_non_integral_or_out_of_range_numbers() {
431 let values = [
433 r#"{"path":"Cargo.toml","offset":-1}"#,
434 r#"{"path":"Cargo.toml","limit":1.5}"#,
435 r#"{"path":"Cargo.toml","limit":1e-1}"#,
436 r#"{"path":"Cargo.toml","offset":18446744073709551616}"#,
437 r#"{"path":"Cargo.toml","offset":1e999999999999999999999}"#,
438 ];
439
440 let errors = values.map(|value| {
442 serde_json::from_str::<ReadArguments>(value)
443 .expect_err("non-integral or out-of-range number should fail")
444 });
445
446 assert!(
448 errors
449 .into_iter()
450 .all(|error| !error.to_string().is_empty())
451 );
452 }
453}