From 5e971fc2f09eb47278d139095744b06218173b55 Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Thu, 8 Oct 2026 03:26:49 +0530 Subject: [PATCH 1/7] feat(apply_patch): take a top-level `path` as the default for edits that omit their own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "One file, several edits" is a natural call shape, and a model writes it with the path once at the top level: `{"path": "a.c", "edits": [{old_string, new_string}, …]}`. The schema required `path` on every edit, so each such call was rejected before the tool ran ("arguments.edits[0].path is required"). In one run 4 of 10 apply_patch calls had that shape; the fourth rejection tripped the no-progress breaker and the turn ended with 26 of 30 minutes unused and the deliverable unwritten. An earlier run lost a task at 51/60 tests the same way. The top-level `path` is now the default for an edit without one; an edit's own path still wins; a call with neither names both ways to give one. The schema declares the top-level property and drops `path` from the per-edit required list. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 7a75657d68dbd43a76c955f1c60ad9f2cdecb8e2) --- .../src/filesystem/apply_patch/mod.rs | 28 ++++++-- .../src/filesystem/apply_patch/mod_tests.rs | 72 +++++++++++++++++++ 2 files changed, 96 insertions(+), 4 deletions(-) diff --git a/crates/tinytools-std/src/filesystem/apply_patch/mod.rs b/crates/tinytools-std/src/filesystem/apply_patch/mod.rs index 2e2f0fb..9b7089a 100644 --- a/crates/tinytools-std/src/filesystem/apply_patch/mod.rs +++ b/crates/tinytools-std/src/filesystem/apply_patch/mod.rs @@ -50,7 +50,8 @@ impl Tool for ApplyPatchTool { fn description(&self) -> &'static str { "Apply a batch of exact-string edits across one or more files atomically. \ All edits are validated before any are written; validation failure rolls \ - back the whole batch. Each edit is `{path, old_string, new_string, replace_all?}`. \ + back the whole batch. Each edit is `{path, old_string, new_string, replace_all?}`; \ + a top-level `path` is the default for edits that omit their own. \ To CREATE a new file, pass an empty `old_string` with the full contents \ as `new_string`; the path must not already exist." } @@ -59,13 +60,20 @@ impl Tool for ApplyPatchTool { json!({ "type": "object", "properties": { + "path": { + "type": "string", + "description": "Default file for edits that omit their own `path`." + }, "edits": { "type": "array", "description": "Ordered list of edits.", "items": { "type": "object", "properties": { - "path": { "type": "string" }, + "path": { + "type": "string", + "description": "File to edit; defaults to the top-level `path`." + }, "old_string": { "type": "string", "description": "Exact text to replace. Empty means CREATE: the path must not exist and `new_string` becomes the whole file." @@ -73,7 +81,7 @@ impl Tool for ApplyPatchTool { "new_string": { "type": "string" }, "replace_all": { "type": "boolean", "default": false } }, - "required": ["path", "old_string", "new_string"] + "required": ["old_string", "new_string"] } } }, @@ -144,13 +152,25 @@ impl ApplyPatchTool { let path_policy = gate_for_context(&self.gate, context, "apply_patch"); + // "One file, several edits" is a natural call shape, and models write + // it with the path once at the top level (4 of 10 calls in one run; + // the per-edit `path` requirement rejected every one of them and the + // run halted on the fourth). The top-level path is the default; an + // edit's own path still wins. + let default_path = args.get("path").and_then(|v| v.as_str()); + // Parse + group edits by file. let mut parsed: Vec = Vec::with_capacity(edits.len()); for (i, raw) in edits.iter().enumerate() { let path = raw .get("path") .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("edit[{i}]: missing `path`"))?; + .or(default_path) + .ok_or_else(|| { + anyhow::anyhow!( + "edit[{i}]: missing `path` (give each edit a `path`, or one top-level `path` for all edits)" + ) + })?; let old_string = raw .get("old_string") .and_then(|v| v.as_str()) diff --git a/crates/tinytools-std/src/filesystem/apply_patch/mod_tests.rs b/crates/tinytools-std/src/filesystem/apply_patch/mod_tests.rs index 391daa0..f6d2fd0 100644 --- a/crates/tinytools-std/src/filesystem/apply_patch/mod_tests.rs +++ b/crates/tinytools-std/src/filesystem/apply_patch/mod_tests.rs @@ -157,6 +157,78 @@ async fn apply_patch_creates_a_new_file_from_an_empty_old_string() { assert!(result.output().contains("created"), "{}", result.output()); } +#[tokio::test] +async fn apply_patch_takes_a_top_level_path_as_the_default_for_every_edit() { + // The shape a model writes for "one file, several edits": the path once, + // at the top level. Each such call used to be rejected for a missing + // `edits[0].path`, and four in a row halted a run with 26 minutes left. + let dir = std::env::temp_dir().join("openhuman_test_patch_top_level_path"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write(dir.join("a.c"), "int x = 1;\nint y = 2;\n") + .await + .unwrap(); + + let tool = ApplyPatchTool::new(test_security(dir.clone())); + let result = tool + .execute(json!({ + "path": "a.c", + "edits": [ + { "old_string": "int x = 1;", "new_string": "int x = 10;" }, + { "old_string": "int y = 2;", "new_string": "int y = 20;" } + ] + })) + .await + .unwrap(); + assert!(!result.is_error, "{}", result.output()); + assert_eq!( + tokio::fs::read_to_string(dir.join("a.c")).await.unwrap(), + "int x = 10;\nint y = 20;\n" + ); +} + +#[tokio::test] +async fn apply_patch_lets_an_edits_own_path_win_over_the_top_level_one() { + let dir = std::env::temp_dir().join("openhuman_test_patch_top_level_path_override"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write(dir.join("a.txt"), "alpha").await.unwrap(); + tokio::fs::write(dir.join("b.txt"), "bravo").await.unwrap(); + + let tool = ApplyPatchTool::new(test_security(dir.clone())); + let result = tool + .execute(json!({ + "path": "a.txt", + "edits": [ + { "old_string": "alpha", "new_string": "ALPHA" }, + { "path": "b.txt", "old_string": "bravo", "new_string": "BRAVO" } + ] + })) + .await + .unwrap(); + assert!(!result.is_error, "{}", result.output()); + assert_eq!(tokio::fs::read_to_string(dir.join("a.txt")).await.unwrap(), "ALPHA"); + assert_eq!(tokio::fs::read_to_string(dir.join("b.txt")).await.unwrap(), "BRAVO"); +} + +#[tokio::test] +async fn apply_patch_names_both_ways_to_give_a_path_when_neither_is_given() { + let dir = std::env::temp_dir().join("openhuman_test_patch_no_path_anywhere"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + + let tool = ApplyPatchTool::new(test_security(dir.clone())); + let err = tool + .execute(json!({ + "edits": [ { "old_string": "a", "new_string": "b" } ] + })) + .await + .expect_err("no path anywhere is an argument error"); + let text = err.to_string(); + assert!(text.contains("edit[0]: missing `path`"), "{text}"); + assert!(text.contains("one top-level `path`"), "{text}"); +} + #[tokio::test] async fn apply_patch_refuses_an_empty_old_string_on_an_existing_file() { let dir = std::env::temp_dir().join("openhuman_test_patch_create_existing"); From 6c79aaff0f2809c278ebe55925b890cf0769acad Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 8 Oct 2026 14:15:23 +0300 Subject: [PATCH 2/7] fix(apply_patch): reject non-string edit paths instead of falling back An edit whose `path` was present but not a string previously fell through to the top-level default path, silently patching the wrong file. Such edits now fail with a clear error, and a test covers the malformed case. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/filesystem/apply_patch/mod.rs | 13 ++++----- .../src/filesystem/apply_patch/mod_tests.rs | 27 +++++++++++++++++++ 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/crates/tinytools-std/src/filesystem/apply_patch/mod.rs b/crates/tinytools-std/src/filesystem/apply_patch/mod.rs index 9b7089a..7326ec5 100644 --- a/crates/tinytools-std/src/filesystem/apply_patch/mod.rs +++ b/crates/tinytools-std/src/filesystem/apply_patch/mod.rs @@ -162,15 +162,16 @@ impl ApplyPatchTool { // Parse + group edits by file. let mut parsed: Vec = Vec::with_capacity(edits.len()); for (i, raw) in edits.iter().enumerate() { - let path = raw - .get("path") - .and_then(|v| v.as_str()) - .or(default_path) - .ok_or_else(|| { + let path = match raw.get("path") { + Some(value) => value.as_str().ok_or_else(|| { + anyhow::anyhow!("edit[{i}]: `path` must be a string") + })?, + None => default_path.ok_or_else(|| { anyhow::anyhow!( "edit[{i}]: missing `path` (give each edit a `path`, or one top-level `path` for all edits)" ) - })?; + })?, + }; let old_string = raw .get("old_string") .and_then(|v| v.as_str()) diff --git a/crates/tinytools-std/src/filesystem/apply_patch/mod_tests.rs b/crates/tinytools-std/src/filesystem/apply_patch/mod_tests.rs index f6d2fd0..0a5f471 100644 --- a/crates/tinytools-std/src/filesystem/apply_patch/mod_tests.rs +++ b/crates/tinytools-std/src/filesystem/apply_patch/mod_tests.rs @@ -187,6 +187,33 @@ async fn apply_patch_takes_a_top_level_path_as_the_default_for_every_edit() { ); } +#[tokio::test] +async fn apply_patch_rejects_malformed_edit_path_instead_of_using_default() { + let dir = std::env::temp_dir().join("openhuman_test_patch_malformed_path"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write(dir.join("default.txt"), "original") + .await + .unwrap(); + + let tool = ApplyPatchTool::new(test_security(dir.clone())); + let result = tool + .execute(json!({ + "path": "default.txt", + "edits": [{ "path": null, "old_string": "original", "new_string": "changed" }] + })) + .await + .unwrap(); + + assert!(result.is_error); + assert!(result.output().contains("`path` must be a string")); + assert_eq!( + tokio::fs::read_to_string(dir.join("default.txt")).await.unwrap(), + "original" + ); + let _ = tokio::fs::remove_dir_all(&dir).await; +} + #[tokio::test] async fn apply_patch_lets_an_edits_own_path_win_over_the_top_level_one() { let dir = std::env::temp_dir().join("openhuman_test_patch_top_level_path_override"); From f5c49031af16e7e4db76dbd22a053bf2e6f7169a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 8 Oct 2026 14:15:26 +0300 Subject: [PATCH 3/7] test(apply_patch): reformat assertions to satisfy line width Wrap the read_to_string assertions in the malformed-path and per-edit-path tests so they fit the formatter's line width. No test behaviour changes. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/filesystem/apply_patch/mod_tests.rs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/crates/tinytools-std/src/filesystem/apply_patch/mod_tests.rs b/crates/tinytools-std/src/filesystem/apply_patch/mod_tests.rs index 0a5f471..8c775c6 100644 --- a/crates/tinytools-std/src/filesystem/apply_patch/mod_tests.rs +++ b/crates/tinytools-std/src/filesystem/apply_patch/mod_tests.rs @@ -208,7 +208,9 @@ async fn apply_patch_rejects_malformed_edit_path_instead_of_using_default() { assert!(result.is_error); assert!(result.output().contains("`path` must be a string")); assert_eq!( - tokio::fs::read_to_string(dir.join("default.txt")).await.unwrap(), + tokio::fs::read_to_string(dir.join("default.txt")) + .await + .unwrap(), "original" ); let _ = tokio::fs::remove_dir_all(&dir).await; @@ -234,8 +236,14 @@ async fn apply_patch_lets_an_edits_own_path_win_over_the_top_level_one() { .await .unwrap(); assert!(!result.is_error, "{}", result.output()); - assert_eq!(tokio::fs::read_to_string(dir.join("a.txt")).await.unwrap(), "ALPHA"); - assert_eq!(tokio::fs::read_to_string(dir.join("b.txt")).await.unwrap(), "BRAVO"); + assert_eq!( + tokio::fs::read_to_string(dir.join("a.txt")).await.unwrap(), + "ALPHA" + ); + assert_eq!( + tokio::fs::read_to_string(dir.join("b.txt")).await.unwrap(), + "BRAVO" + ); } #[tokio::test] From d8407ea216a52c714f465c3d6f5434aaf1340e70 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 8 Oct 2026 14:16:12 +0300 Subject: [PATCH 4/7] test(apply_patch): align malformed path test with error return The malformed edit path test now expects the call to return an error instead of a result flagged as an error, matching the updated signature and asserting on the error message directly. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/filesystem/apply_patch/mod_tests.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/tinytools-std/src/filesystem/apply_patch/mod_tests.rs b/crates/tinytools-std/src/filesystem/apply_patch/mod_tests.rs index 8c775c6..5cef7ab 100644 --- a/crates/tinytools-std/src/filesystem/apply_patch/mod_tests.rs +++ b/crates/tinytools-std/src/filesystem/apply_patch/mod_tests.rs @@ -202,11 +202,15 @@ async fn apply_patch_rejects_malformed_edit_path_instead_of_using_default() { "path": "default.txt", "edits": [{ "path": null, "old_string": "original", "new_string": "changed" }] })) - .await - .unwrap(); + .await; - assert!(result.is_error); - assert!(result.output().contains("`path` must be a string")); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("`path` must be a string") + ); assert_eq!( tokio::fs::read_to_string(dir.join("default.txt")) .await From 0d6d9faba052203c9c4fdb901736bac30d6692c2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 8 Oct 2026 14:18:54 +0300 Subject: [PATCH 5/7] feat(filesystem): add top-level path default to apply_patch The apply_patch tool now accepts an optional top-level path that serves as the default file for edits omitting their own, so callers editing a single file no longer need to repeat the path on every edit. The per-edit path is no longer required and its description documents the fallback. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/filesystem/fixtures/apply_patch.json | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/tinytools-std/src/filesystem/fixtures/apply_patch.json b/crates/tinytools-std/src/filesystem/fixtures/apply_patch.json index 89b3b67..7b98f41 100644 --- a/crates/tinytools-std/src/filesystem/fixtures/apply_patch.json +++ b/crates/tinytools-std/src/filesystem/fixtures/apply_patch.json @@ -1,10 +1,14 @@ { - "description": "Apply a batch of exact-string edits across one or more files atomically. All edits are validated before any are written; validation failure rolls back the whole batch. Each edit is `{path, old_string, new_string, replace_all?}`. To CREATE a new file, pass an empty `old_string` with the full contents as `new_string`; the path must not already exist.", + "description": "Apply a batch of exact-string edits across one or more files atomically. All edits are validated before any are written; validation failure rolls back the whole batch. Each edit is `{path, old_string, new_string, replace_all?}`; a top-level `path` is the default for edits that omit their own. To CREATE a new file, pass an empty `old_string` with the full contents as `new_string`; the path must not already exist.", "exposure": "Direct", "name": "apply_patch", "permission_level": "Write", "schema": { "properties": { + "path": { + "description": "Default file for edits that omit their own `path`.", + "type": "string" + }, "edits": { "description": "Ordered list of edits.", "items": { @@ -17,6 +21,7 @@ "type": "string" }, "path": { + "description": "File to edit; defaults to the top-level `path`.", "type": "string" }, "replace_all": { @@ -25,7 +30,6 @@ } }, "required": [ - "path", "old_string", "new_string" ], From 47ca2bad70ea33db22a1515cef22700fb32f6d0b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 8 Oct 2026 14:22:35 +0300 Subject: [PATCH 6/7] fix(apply_patch): reject non-string top-level path A non-string top-level `path` was silently treated as absent, so edits fell back to their own paths instead of failing. It now returns an error, with a test covering the malformed input. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/filesystem/apply_patch/mod.rs | 9 +++++++- .../src/filesystem/apply_patch/mod_tests.rs | 21 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/tinytools-std/src/filesystem/apply_patch/mod.rs b/crates/tinytools-std/src/filesystem/apply_patch/mod.rs index 7326ec5..5311d14 100644 --- a/crates/tinytools-std/src/filesystem/apply_patch/mod.rs +++ b/crates/tinytools-std/src/filesystem/apply_patch/mod.rs @@ -157,7 +157,14 @@ impl ApplyPatchTool { // the per-edit `path` requirement rejected every one of them and the // run halted on the fourth). The top-level path is the default; an // edit's own path still wins. - let default_path = args.get("path").and_then(|v| v.as_str()); + let default_path = match args.get("path") { + Some(value) => Some( + value + .as_str() + .ok_or_else(|| anyhow::anyhow!("top-level `path` must be a string"))?, + ), + None => None, + }; // Parse + group edits by file. let mut parsed: Vec = Vec::with_capacity(edits.len()); diff --git a/crates/tinytools-std/src/filesystem/apply_patch/mod_tests.rs b/crates/tinytools-std/src/filesystem/apply_patch/mod_tests.rs index 5cef7ab..34e9922 100644 --- a/crates/tinytools-std/src/filesystem/apply_patch/mod_tests.rs +++ b/crates/tinytools-std/src/filesystem/apply_patch/mod_tests.rs @@ -220,6 +220,27 @@ async fn apply_patch_rejects_malformed_edit_path_instead_of_using_default() { let _ = tokio::fs::remove_dir_all(&dir).await; } +#[tokio::test] +async fn apply_patch_rejects_a_malformed_top_level_path() { + let dir = std::env::temp_dir().join("openhuman_test_patch_malformed_top_level_path"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write(dir.join("a.txt"), "original").await.unwrap(); + + let tool = ApplyPatchTool::new(test_security(dir.clone())); + let result = tool + .execute(json!({ + "path": 123, + "edits": [{ "path": "a.txt", "old_string": "original", "new_string": "changed" }] + })) + .await; + + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("top-level `path` must be a string")); + assert_eq!(tokio::fs::read_to_string(dir.join("a.txt")).await.unwrap(), "original"); + let _ = tokio::fs::remove_dir_all(&dir).await; +} + #[tokio::test] async fn apply_patch_lets_an_edits_own_path_win_over_the_top_level_one() { let dir = std::env::temp_dir().join("openhuman_test_patch_top_level_path_override"); From bfd90b45996b4bf993c870466f5e3e9a21c8925b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 8 Oct 2026 14:22:44 +0300 Subject: [PATCH 7/7] test(apply_patch): reformat malformed top-level path assertions Reformat the assertions in the malformed top-level path test to satisfy rustfmt line width limits. No behaviour change. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/filesystem/apply_patch/mod_tests.rs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/crates/tinytools-std/src/filesystem/apply_patch/mod_tests.rs b/crates/tinytools-std/src/filesystem/apply_patch/mod_tests.rs index 34e9922..5cb7c9e 100644 --- a/crates/tinytools-std/src/filesystem/apply_patch/mod_tests.rs +++ b/crates/tinytools-std/src/filesystem/apply_patch/mod_tests.rs @@ -225,7 +225,9 @@ async fn apply_patch_rejects_a_malformed_top_level_path() { let dir = std::env::temp_dir().join("openhuman_test_patch_malformed_top_level_path"); let _ = tokio::fs::remove_dir_all(&dir).await; tokio::fs::create_dir_all(&dir).await.unwrap(); - tokio::fs::write(dir.join("a.txt"), "original").await.unwrap(); + tokio::fs::write(dir.join("a.txt"), "original") + .await + .unwrap(); let tool = ApplyPatchTool::new(test_security(dir.clone())); let result = tool @@ -236,8 +238,16 @@ async fn apply_patch_rejects_a_malformed_top_level_path() { .await; assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("top-level `path` must be a string")); - assert_eq!(tokio::fs::read_to_string(dir.join("a.txt")).await.unwrap(), "original"); + assert!( + result + .unwrap_err() + .to_string() + .contains("top-level `path` must be a string") + ); + assert_eq!( + tokio::fs::read_to_string(dir.join("a.txt")).await.unwrap(), + "original" + ); let _ = tokio::fs::remove_dir_all(&dir).await; }