Skip to main content

ag_harness/
tool.rs

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/// Built-in tool that can be enabled for a harness run.
12#[derive(Clone, Copy, Debug, Eq, PartialEq)]
13pub enum Tool {
14    /// Repository-relative file reads.
15    Read,
16}
17
18/// Provider-neutral definition of a native model tool.
19///
20/// Definitions describe only the wire contract advertised to a model. They do
21/// not execute tools or access the filesystem.
22#[derive(Clone, Debug, Eq, PartialEq)]
23pub struct ToolDefinition {
24    description: &'static str,
25    name: &'static str,
26    parameters: Value,
27}
28
29impl ToolDefinition {
30    /// Defines the native `read` function tool.
31    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    /// Returns the description sent with the native function definition.
61    pub fn description(&self) -> &'static str {
62        self.description
63    }
64
65    /// Returns the native function name.
66    pub fn name(&self) -> &'static str {
67        self.name
68    }
69
70    /// Returns the JSON Schema for the native function arguments.
71    pub fn parameters(&self) -> &Value {
72        &self.parameters
73    }
74}
75
76/// Provider-neutral model request for one native tool invocation.
77#[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    /// Returns the typed arguments supplied to the `read` function.
102    pub fn arguments(&self) -> &ReadArguments {
103        &self.arguments
104    }
105
106    /// Returns the provider-assigned call identifier.
107    pub fn id(&self) -> &str {
108        &self.id
109    }
110
111    /// Returns the requested native function name.
112    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/// Validated arguments for the native `read` function.
139///
140/// `path` is a non-empty repository-relative POSIX path. `offset`, when
141/// present, is a one-based line number, and `limit`, when present, is a
142/// positive maximum line count.
143#[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    /// Returns the optional positive maximum line count.
164    pub fn limit(&self) -> Option<u64> {
165        self.limit.map(NonZeroU64::get)
166    }
167
168    /// Returns the optional one-based starting line.
169    pub fn offset(&self) -> Option<u64> {
170        self.offset.map(NonZeroU64::get)
171    }
172
173    /// Returns the repository-relative path to read.
174    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        // Arrange and Act
265        let definition = ToolDefinition::read();
266        let validator =
267            Validator::new(definition.parameters()).expect("read argument schema should compile");
268
269        // Assert
270        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        // Arrange
296        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        // Act
322        let results = invalid_arguments.map(|arguments| validator.is_valid(&arguments));
323
324        // Assert
325        assert!(results.into_iter().all(|is_valid| !is_valid));
326    }
327
328    #[test]
329    fn read_arguments_reject_invalid_repository_paths() {
330        // Arrange
331        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        // Act
343        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
349        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        // Arrange
359        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        // Act
366        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
374        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        // Arrange
386        let value = json!({
387            "path": "Cargo.toml",
388            "offset": u64::MAX,
389            "limit": u64::MAX
390        });
391
392        // Act
393        let arguments = serde_json::from_value::<ReadArguments>(value)
394            .expect("maximum u64 ranges should decode");
395
396        // Assert
397        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        // Arrange
404        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        // Act
417        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
424        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        // Arrange
432        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        // Act
441        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
447        assert!(
448            errors
449                .into_iter()
450                .all(|error| !error.to_string().is_empty())
451        );
452    }
453}