返回 CodeWhale
file_tool.rs
根目录 / crates / tui / src / tools / file_tool.rs
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
503 lines RUST