| 1 | //! Model-facing file tools and the legacy action-family compatibility wrapper. |
| 2 | //! |
| 3 | //! New turns see the small small-contract-style `read`, `write`, and `edit` primitives. |
| 4 | //! `File` remains registered but hidden so saved v0.9.x transcripts can replay |
| 5 | //! without teaching new sessions the older action-family schema. |
| 6 | |
| 7 | use async_trait::async_trait; |
| 8 | use serde_json::{Value, json}; |
| 9 | |
| 10 | use super::apply_patch::ApplyPatchTool; |
| 11 | use super::canonical_action::required_action; |
| 12 | use super::file::{EditFileTool, ListDirTool, ReadFileTool, WriteFileTool}; |
| 13 | use super::file_search::FileSearchTool; |
| 14 | use super::search::GrepFilesTool; |
| 15 | use super::spec::{ |
| 16 | ApprovalRequirement, RichToolResult, ToolCapability, ToolContext, ToolError, ToolResult, |
| 17 | ToolSpec, |
| 18 | }; |
| 19 | |
| 20 | /// Lift a parameter description out of the tool that implements the action. |
| 21 | /// |
| 22 | /// Returns an empty string when the key is absent, which the |
| 23 | /// `wrapper_borrows_every_inner_description` test turns into a build-time |
| 24 | /// failure — a renamed inner parameter must not silently blank the only |
| 25 | /// description the model gets to read. |
| 26 | fn borrowed(schema: &Value, key: &str) -> String { |
| 27 | schema |
| 28 | .get("properties") |
| 29 | .and_then(|properties| properties.get(key)) |
| 30 | .and_then(|property| property.get("description")) |
| 31 | .and_then(Value::as_str) |
| 32 | .unwrap_or_default() |
| 33 | .to_string() |
| 34 | } |
| 35 | |
| 36 | /// `borrowed`, tagged with the action the parameter belongs to. |
| 37 | fn describe(schema: &Value, key: &str, action: &str) -> String { |
| 38 | let base = borrowed(schema, key); |
| 39 | if base.is_empty() { |
| 40 | return String::new(); |
| 41 | } |
| 42 | format!("{} — {action}.", base.trim_end_matches('.')) |
| 43 | } |
| 44 | |
| 45 | pub struct FileTool { |
| 46 | name: &'static str, |
| 47 | allow_writes: bool, |
| 48 | allow_patch: bool, |
| 49 | } |
| 50 | |
| 51 | /// Small model-facing reader with the same public contract as the small-contract built-in |
| 52 | /// `read` tool. The legacy [`ReadFileTool`] remains separately registered for |
| 53 | /// saved Codewhale transcripts. |
| 54 | pub struct ReadTool; |
| 55 | |
| 56 | #[async_trait] |
| 57 | impl ToolSpec for ReadTool { |
| 58 | fn name(&self) -> &'static str { |
| 59 | "read" |
| 60 | } |
| 61 | |
| 62 | fn description(&self) -> &'static str { |
| 63 | "Read a text file. The whole file comes back in one call when it fits this call's output budget — 100000 bytes by default, raisable to 500000 with max_bytes. There is no line cap. Use offset and limit for an exact line range; when output is budget-limited the footer names the exact offset to continue from. Every response reports the file's byte size, line count, and whether output was truncated." |
| 64 | } |
| 65 | |
| 66 | fn input_schema(&self) -> Value { |
| 67 | json!({ |
| 68 | "type": "object", |
| 69 | "properties": { |
| 70 | "path": { |
| 71 | "type": "string", |
| 72 | "description": "Path to the file to read (relative or absolute)." |
| 73 | }, |
| 74 | "offset": { |
| 75 | "type": "number", |
| 76 | "description": "Line number to start reading from (1-indexed)." |
| 77 | }, |
| 78 | "limit": { |
| 79 | "type": "number", |
| 80 | "description": "Maximum number of lines to read." |
| 81 | }, |
| 82 | "max_bytes": { |
| 83 | "type": "number", |
| 84 | "description": "Output budget in bytes for this one call. Defaults to 100000; values above the 500000 maximum are clamped down rather than rejected, and a value below the active default leaves the default in place." |
| 85 | } |
| 86 | }, |
| 87 | "required": ["path"], |
| 88 | "additionalProperties": false |
| 89 | }) |
| 90 | } |
| 91 | |
| 92 | fn capabilities(&self) -> Vec<ToolCapability> { |
| 93 | ReadFileTool.capabilities() |
| 94 | } |
| 95 | |
| 96 | fn supports_parallel(&self) -> bool { |
| 97 | true |
| 98 | } |
| 99 | |
| 100 | async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> { |
| 101 | ReadFileTool::execute_contract_read(input, context) |
| 102 | .await |
| 103 | .map(RichToolResult::into_result) |
| 104 | } |
| 105 | |
| 106 | async fn execute_rich( |
| 107 | &self, |
| 108 | input: Value, |
| 109 | context: &ToolContext, |
| 110 | ) -> Result<RichToolResult, ToolError> { |
| 111 | ReadFileTool::execute_contract_read(input, context).await |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | /// Small model-facing whole-file writer backed by [`WriteFileTool`]. |
| 116 | pub struct WriteTool; |
| 117 | |
| 118 | #[async_trait] |
| 119 | impl ToolSpec for WriteTool { |
| 120 | fn name(&self) -> &'static str { |
| 121 | "write" |
| 122 | } |
| 123 | |
| 124 | fn description(&self) -> &'static str { |
| 125 | "Write content to a file. Creates the file if it does not exist, overwrites it if it does, and creates parent directories automatically." |
| 126 | } |
| 127 | |
| 128 | fn input_schema(&self) -> Value { |
| 129 | json!({ |
| 130 | "type": "object", |
| 131 | "properties": { |
| 132 | "path": { |
| 133 | "type": "string", |
| 134 | "description": "Path to the file to write (relative or absolute)." |
| 135 | }, |
| 136 | "content": { "type": "string", "description": "Content to write to the file." } |
| 137 | }, |
| 138 | "required": ["path", "content"], |
| 139 | "additionalProperties": false |
| 140 | }) |
| 141 | } |
| 142 | |
| 143 | fn capabilities(&self) -> Vec<ToolCapability> { |
| 144 | WriteFileTool.capabilities() |
| 145 | } |
| 146 | |
| 147 | fn approval_requirement(&self) -> ApprovalRequirement { |
| 148 | WriteFileTool.approval_requirement() |
| 149 | } |
| 150 | |
| 151 | async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> { |
| 152 | WriteFileTool::execute_contract_write(input, context).await |
| 153 | } |
| 154 | } |
| 155 | |
| 156 | /// small-contract-style multi-edit surface. Every `oldText` is resolved against the same |
| 157 | /// original file and ranges must be unique and non-overlapping. |
| 158 | pub struct EditTool; |
| 159 | |
| 160 | #[async_trait] |
| 161 | impl ToolSpec for EditTool { |
| 162 | fn name(&self) -> &'static str { |
| 163 | "edit" |
| 164 | } |
| 165 | |
| 166 | fn description(&self) -> &'static str { |
| 167 | "Edit one file with targeted text replacements. Every edits[].oldText must identify a unique, non-overlapping region of the original file. Merge nearby or overlapping changes into one edit." |
| 168 | } |
| 169 | |
| 170 | fn input_schema(&self) -> Value { |
| 171 | json!({ |
| 172 | "type": "object", |
| 173 | "properties": { |
| 174 | "path": { |
| 175 | "type": "string", |
| 176 | "description": "Path to the file to edit (relative or absolute)." |
| 177 | }, |
| 178 | "edits": { |
| 179 | "type": "array", |
| 180 | "items": { |
| 181 | "type": "object", |
| 182 | "properties": { |
| 183 | "oldText": { "type": "string", "description": "Text identifying one unique region to replace." }, |
| 184 | "newText": { |
| 185 | "type": "string", |
| 186 | "description": "Replacement text; may be empty to delete the matched span." |
| 187 | } |
| 188 | }, |
| 189 | "required": ["oldText", "newText"], |
| 190 | "additionalProperties": false |
| 191 | }, |
| 192 | "description": "One or more disjoint replacements, all matched against the original file." |
| 193 | } |
| 194 | }, |
| 195 | "required": ["path", "edits"], |
| 196 | "additionalProperties": false |
| 197 | }) |
| 198 | } |
| 199 | |
| 200 | fn capabilities(&self) -> Vec<ToolCapability> { |
| 201 | EditFileTool.capabilities() |
| 202 | } |
| 203 | |
| 204 | fn approval_requirement(&self) -> ApprovalRequirement { |
| 205 | EditFileTool.approval_requirement() |
| 206 | } |
| 207 | |
| 208 | async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> { |
| 209 | EditFileTool::execute_contract_edits(input, context).await |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | impl FileTool { |
| 214 | pub const fn new(name: &'static str) -> Self { |
| 215 | Self { |
| 216 | name, |
| 217 | allow_writes: true, |
| 218 | allow_patch: false, |
| 219 | } |
| 220 | } |
| 221 | |
| 222 | pub const fn with_patch(name: &'static str) -> Self { |
| 223 | Self { |
| 224 | name, |
| 225 | allow_writes: true, |
| 226 | allow_patch: true, |
| 227 | } |
| 228 | } |
| 229 | |
| 230 | #[cfg_attr(not(test), expect(dead_code))] |
| 231 | pub const fn read_only(name: &'static str) -> Self { |
| 232 | Self { |
| 233 | name, |
| 234 | allow_writes: false, |
| 235 | allow_patch: false, |
| 236 | } |
| 237 | } |
| 238 | |
| 239 | /// The action set this instance actually dispatches, in schema order. |
| 240 | /// |
| 241 | /// The `enum` the model reads and the name list its errors quote are both |
| 242 | /// built from this, so a mode that hides `write` can never advertise it or |
| 243 | /// suggest it in a refusal. |
| 244 | fn available_actions(&self) -> Vec<&'static str> { |
| 245 | let mut actions = vec!["read", "list", "search_name", "search_content"]; |
| 246 | if self.allow_writes { |
| 247 | actions.extend(["write", "edit"]); |
| 248 | } |
| 249 | if self.allow_patch { |
| 250 | actions.push("patch"); |
| 251 | } |
| 252 | actions |
| 253 | } |
| 254 | |
| 255 | /// Policy-side action resolution. |
| 256 | /// |
| 257 | /// Approval, read-only, and parallel-safety predicates cannot fail, so a |
| 258 | /// missing action resolves to the most restrictive answer they can give. |
| 259 | /// `execute` does **not** share this fallback — see `required_action`. |
| 260 | fn resolve_action<'a>(&self, input: &'a Value) -> &'a str { |
| 261 | input |
| 262 | .get("action") |
| 263 | .and_then(Value::as_str) |
| 264 | .unwrap_or("read") |
| 265 | } |
| 266 | |
| 267 | /// Execution-side action resolution: `action` is required, as the schema |
| 268 | /// has always said it is. |
| 269 | fn required_action(&self, input: &Value) -> Result<String, ToolError> { |
| 270 | required_action(input, self.name, &self.available_actions()) |
| 271 | } |
| 272 | |
| 273 | fn strip_action(&self, input: Value) -> Result<Value, ToolError> { |
| 274 | let mut input = input; |
| 275 | if let Some(obj) = input.as_object_mut() { |
| 276 | obj.remove("action"); |
| 277 | Ok(input) |
| 278 | } else { |
| 279 | Err(ToolError::invalid_input( |
| 280 | "File tool input must be an object", |
| 281 | )) |
| 282 | } |
| 283 | } |
| 284 | } |
| 285 | |
| 286 | #[async_trait] |
| 287 | impl ToolSpec for FileTool { |
| 288 | fn name(&self) -> &'static str { |
| 289 | self.name |
| 290 | } |
| 291 | |
| 292 | fn model_visible(&self) -> bool { |
| 293 | false |
| 294 | } |
| 295 | |
| 296 | fn description(&self) -> &'static str { |
| 297 | "Read, list, search, write, edit, or patch workspace files. Use read before edit; edit performs one exact replacement, while patch is best for multi-hunk or multi-file changes. Read/list/search actions are parallel-safe and do not require approval. Available actions depend on the active mode and feature policy." |
| 298 | } |
| 299 | |
| 300 | fn input_schema(&self) -> Value { |
| 301 | let actions = self.available_actions(); |
| 302 | // Every per-action parameter description is borrowed from the tool |
| 303 | // that implements the action rather than restated here. The wrapper is |
| 304 | // the only schema the model ever reads, and the restated copy had gone |
| 305 | // stale: it advertised `max_lines` "default 200" long after the real |
| 306 | // default became 500-with-a-16KB-budget, and a `blame` action `File` |
| 307 | // has never had. Borrowing makes that class of drift impossible. |
| 308 | let read = ReadFileTool.input_schema(); |
| 309 | let name_search = FileSearchTool.input_schema(); |
| 310 | let content_search = GrepFilesTool.input_schema(); |
| 311 | let write = WriteFileTool.input_schema(); |
| 312 | let edit = EditFileTool.input_schema(); |
| 313 | let patch = ApplyPatchTool.input_schema(); |
| 314 | json!({ |
| 315 | "type": "object", |
| 316 | "properties": { |
| 317 | "action": { |
| 318 | "type": "string", |
| 319 | "enum": actions, |
| 320 | "description": "Action to perform" |
| 321 | }, |
| 322 | "path": { |
| 323 | "type": "string", |
| 324 | "description": format!( |
| 325 | "{} Optional for list and search (default: .).", |
| 326 | borrowed(&read, "path"), |
| 327 | ) |
| 328 | }, |
| 329 | "start_line": { |
| 330 | "type": "integer", |
| 331 | "description": describe(&read, "start_line", "action=read") |
| 332 | }, |
| 333 | "max_lines": { |
| 334 | "type": "integer", |
| 335 | "description": describe(&read, "max_lines", "action=read") |
| 336 | }, |
| 337 | "pages": { |
| 338 | "type": "string", |
| 339 | "description": describe(&read, "pages", "action=read") |
| 340 | }, |
| 341 | "content": { |
| 342 | "type": "string", |
| 343 | "description": describe(&write, "content", "action=write") |
| 344 | }, |
| 345 | "search": { |
| 346 | "type": "string", |
| 347 | "description": describe(&edit, "search", "action=edit") |
| 348 | }, |
| 349 | "replace": { |
| 350 | "oneOf": [ |
| 351 | { "type": "string", "description": describe(&edit, "replace", "action=edit") }, |
| 352 | { "type": "array", "items": { "type": "object", "properties": { "path": { "type": "string" }, "content": { "type": "string" } }, "required": ["path", "content"] }, "description": "Full-file replacements — action=patch." } |
| 353 | ] |
| 354 | }, |
| 355 | "fuzz": { |
| 356 | "type": "integer", |
| 357 | "description": describe(&patch, "fuzz", "action=patch") |
| 358 | }, |
| 359 | "query": { |
| 360 | "type": "string", |
| 361 | "description": describe(&name_search, "query", "action=search_name") |
| 362 | }, |
| 363 | "pattern": { |
| 364 | "type": "string", |
| 365 | "description": describe(&content_search, "pattern", "action=search_content") |
| 366 | }, |
| 367 | "limit": { |
| 368 | "type": "integer", |
| 369 | "description": describe(&name_search, "limit", "action=search_name") |
| 370 | }, |
| 371 | "max_results": { |
| 372 | "type": "integer", |
| 373 | "description": format!( |
| 374 | "{} search_name: alias for `limit`.", |
| 375 | describe(&content_search, "max_results", "action=search_content"), |
| 376 | ) |
| 377 | }, |
| 378 | "extensions": { |
| 379 | "type": "array", |
| 380 | "items": { "type": "string" }, |
| 381 | "description": describe(&name_search, "extensions", "action=search_name") |
| 382 | }, |
| 383 | "include": { |
| 384 | "type": "array", |
| 385 | "items": { "type": "string" }, |
| 386 | "description": describe(&content_search, "include", "action=search_content") |
| 387 | }, |
| 388 | "exclude": { |
| 389 | "type": "array", |
| 390 | "items": { "type": "string" }, |
| 391 | "description": format!( |
| 392 | "{} Also action=search_name.", |
| 393 | describe(&content_search, "exclude", "action=search_content"), |
| 394 | ) |
| 395 | }, |
| 396 | "context_lines": { |
| 397 | "type": "integer", |
| 398 | "description": describe(&content_search, "context_lines", "action=search_content") |
| 399 | }, |
| 400 | "case_insensitive": { |
| 401 | "type": "boolean", |
| 402 | "description": describe(&content_search, "case_insensitive", "action=search_content") |
| 403 | }, |
| 404 | "patch": { |
| 405 | "type": "string", |
| 406 | "description": "Unified diff patch content for action=patch" |
| 407 | }, |
| 408 | "changes": { |
| 409 | "type": "array", |
| 410 | "items": { "type": "object", "properties": { "path": { "type": "string" }, "content": { "type": "string" } }, "required": ["path", "content"] }, |
| 411 | "description": "Deprecated alias for replace in action=patch" |
| 412 | }, |
| 413 | "create_if_missing": { |
| 414 | "type": "boolean", |
| 415 | "description": "Create files if missing for action=patch" |
| 416 | }, |
| 417 | "expected_hash": { |
| 418 | "type": "string", |
| 419 | "description": describe(&edit, "expected_hash", "action=write/edit/patch") |
| 420 | } |
| 421 | }, |
| 422 | "required": ["action"] |
| 423 | }) |
| 424 | } |
| 425 | |
| 426 | fn capabilities(&self) -> Vec<ToolCapability> { |
| 427 | let mut capabilities = vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable]; |
| 428 | if self.allow_writes || self.allow_patch { |
| 429 | capabilities.extend([ |
| 430 | ToolCapability::WritesFiles, |
| 431 | ToolCapability::RequiresApproval, |
| 432 | ]); |
| 433 | } |
| 434 | capabilities |
| 435 | } |
| 436 | |
| 437 | fn approval_requirement_for(&self, input: &Value) -> ApprovalRequirement { |
| 438 | match self.resolve_action(input) { |
| 439 | "read" | "list" | "search_name" | "search_content" => ApprovalRequirement::Auto, |
| 440 | "write" | "edit" | "patch" => ApprovalRequirement::Suggest, |
| 441 | _ => ApprovalRequirement::Auto, |
| 442 | } |
| 443 | } |
| 444 | |
| 445 | fn is_read_only_for(&self, input: &Value) -> bool { |
| 446 | matches!( |
| 447 | self.resolve_action(input), |
| 448 | "read" | "list" | "search_name" | "search_content" |
| 449 | ) |
| 450 | } |
| 451 | |
| 452 | fn supports_parallel_for(&self, input: &Value) -> bool { |
| 453 | matches!( |
| 454 | self.resolve_action(input), |
| 455 | "read" | "list" | "search_name" | "search_content" |
| 456 | ) |
| 457 | } |
| 458 | |
| 459 | fn starts_detached_for(&self, _input: &Value) -> bool { |
| 460 | false |
| 461 | } |
| 462 | |
| 463 | async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> { |
| 464 | let action = self.required_action(&input)?; |
| 465 | if matches!(action.as_str(), "write" | "edit") && !self.allow_writes { |
| 466 | return Err(ToolError::not_available(format!( |
| 467 | "File action=\"{action}\" is unavailable in the current mode; nothing was written. Available actions here: {}. Switch to Work mode (`/mode work`) for write-capable file work.", |
| 468 | self.available_actions().join(", ") |
| 469 | ))); |
| 470 | } |
| 471 | if action == "patch" && !self.allow_patch { |
| 472 | return Err(ToolError::not_available(format!( |
| 473 | "File action=\"patch\" is unavailable because the patch feature is disabled; nothing was written. Available actions here: {}. When writes are available in this mode, action=\"edit\" replaces a single exact match and action=\"write\" replaces the whole file.", |
| 474 | self.available_actions().join(", ") |
| 475 | ))); |
| 476 | } |
| 477 | let input = self.strip_action(input)?; |
| 478 | |
| 479 | match action.as_str() { |
| 480 | "read" => ReadFileTool.execute(input, context).await, |
| 481 | "list" => ListDirTool.execute(input, context).await, |
| 482 | // The cross-action spellings the wrapper advertises |
| 483 | // (`max_results` on search_name, `query`/`limit` on |
| 484 | // search_content) used to be copied here. They are alias-table |
| 485 | // entries on the implementing tools now — one mechanism, applied |
| 486 | // before the same unknown-parameter check every other action runs, |
| 487 | // and a direct call to the inner tool behaves identically. |
| 488 | "search_name" => FileSearchTool.execute(input, context).await, |
| 489 | "search_content" => GrepFilesTool.execute(input, context).await, |
| 490 | "write" => WriteFileTool.execute(input, context).await, |
| 491 | "edit" => EditFileTool.execute(input, context).await, |
| 492 | "patch" => ApplyPatchTool.execute(input, context).await, |
| 493 | other => Err(ToolError::invalid_input(format!( |
| 494 | "Unknown File action \"{other}\"; nothing was run. Pass one of: {}.", |
| 495 | self.available_actions().join(", ") |
| 496 | ))), |
| 497 | } |
| 498 | } |
| 499 | } |
| 500 | |
| 501 | #[cfg(test)] |
| 502 | mod tests; |
| 503 |