Guard review of the editing tools found the new pair weaker than the tools they sit beside — not in what they can do, but in what refuses them. Root cause: a guard judged a different spelling of the path than the write used. `ctx.repo_path` runs `normalize_root_relative`, so an absolute path inside the root and a redundant root-basename prefix land on the same file a bare relative path does; `_resolve_edit_target` checked the RAW spelling, for which `repo/BIBLE.md` is not a member of the protected-path table while `BIBLE.md` is. In runtime_mode=advanced, `apply_patch`/`edit_batch` therefore rewrote BIBLE.md and ouroboros/safety.py where edit_text returns CORE_PROTECTION_BLOCKED. Reproduced against the real dispatcher, both spellings, on the default root. `edit_text`/`write_file` are immune only because the dispatcher canonicalizes their `path` ARG once (`_PATH_NORMALIZED_TOOLS`). The new tools carry paths inside the payload, so the module comment claiming their handlers "normalize each target themselves via the shared edit_text guard chain" described a normalization the shared chain never had. One canonicalization contract fixes the whole class: - `tool_access.canonical_repo_relative_path` is the single normalization both ends call: the handler before its own protected checks, and the dispatch gates through `_payload_write_paths`, which reads apply_patch's targets back out of the REAL parser (`patch_target_paths`) so the gate cannot drift. - `_resolve_edit_target` RETURNS that canonical rel, because it is the file's identity. Keying the plan by the raw spelling meant two spellings of one file in a single call produced two buffers and two writes, and the last silently discarded the first while the result reported both as applied — reproduced as real data loss on edit_batch and apply_patch. - `_ROOT_ARG_REPO_WRITE_TOOLS` replaces the hardcoded write_file/edit_text pair at the three fences that missed the new tools: the acting-no-workspace fence (an acting child with no isolated workspace reached the LIVE repo), the protected-write gate (which also carries the pro + protected_paths_grant condition), and the acting root-enum narrowing. Parity with the other repo writers, item by item: - The assisted managed-update resolver keeps its protected-path exemption here too; without it these tools were the one lane that could not finish a conflict resolution. - A pro-mode protected edit appends `core_patch_notice`, as git._repo_write and _str_replace_editor do. The mode ALLOWS the write; the notice is what keeps it visible. - Validation refusals get the typed `edit_ops_blocked` status: a counted or context miss is the designed path and is user-correctable exactly like edit_text's "old_str not found", so it is a policy denial, not the false tool_failure headline v6.57.0 removed for the other write tools. A partial write is NOT that: it carries its own `EDIT_OPS_PARTIAL_WRITE_FAILED` prefix and stays a real execution failure. - A write failure after some files were written invalidates the advisory snapshot for them and says PARTIALLY APPLIED, so commit_reviewed cannot accept them against a pre-review taken before they existed. Honest contracts instead of implied ones: - Atomicity is over VALIDATION. Every schema, prompt and doc that implied the writes roll back now says so, and the residual (a mid-write I/O fault can leave earlier files applied) is disclosed rather than papered over. - The fuzzy trailing-whitespace fallback states what it actually did: the replaced lines, context included, now carry the patch's trailing whitespace. - `_unified_diff` reports a final-newline change instead of "(no textual changes)" — the rail exists to let the agent verify an overwrite, so the one answer it must never give is "nothing changed" for changed bytes. - `_syntax_check` names the format it actually checked (a NUL byte in a .py file reported "not valid JSON"). - `edit_batch` gets apply_patch's 80k result limit: an aborted batch reports every failed edit so one retry can fix them all. Newline handling is deliberately NOT changed: the repo-write lane reads with universal newlines and writes \n, so edit_text and write_file flatten CRLF the same way today. Documented in the module rather than diverging one tool from its lane. editbench: generated `fixtures_v2/` is gitignored (running the benchmark dirtied the seed gate), missing fixture trees fail fast before the isolated server and the paid jobs rather than inside copytree, the run root is DERIVED instead of mkdtemp'd so a seed-gate refusal leaves no footprint ahead of admission, and the README and launcher docstring stop offering `edit_sketch_only` and a `default` config they no longer have. Tests exercise the REAL guard chain (a registry over a throwaway repo, no patched resolver): protected paths refused in all three spellings for all three tools, the acting-no-workspace fence for all three write tools, one file under two spellings staying one target, the pro-mode notice, the resolver exemption, parser-derived patch targets, partial-write disclosure and its status, and the diff rail. DEVELOPMENT's New Tool rule now names the guard surfaces, since every visibility list was already green while these fences were missing. Co-Authored-By: Andrei Kaznacheev <a.kaznacheev@sdgroup.ai> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com> |
||
|---|---|---|
| .. | ||
| checks | ||
| fixtures | ||
| checker.py | ||
| make_fixtures_v2.py | ||
| README.md | ||
| run_editbench.py | ||
editbench — file-editing tool benchmark
Outcome note. This benchmark drove the final toolset decision:
apply_patchandedit_batchwere adopted;edit_sketch(fast-apply via the LIGHT model) was evaluated here and REMOVED — the sketch/apply split never beat direct tools on cost or robustness. Its verification rails (unified diff in the result, pre-write syntax check) moved intowrite_file. The edit_sketch rows below are the historical evaluation data.
Measures Ouroboros' file-editing tools on a trap-laden multi-file rename task: correctness, tokens, wall time, tool-call error rates, and which tools the agent picks when free to choose.
Task
fixtures/toyproj/ is an 8-file Python project. The task: rename every
module-level function named exactly ddd to aaa (def lines, calls,
imports, aliases, qualified refs) while leaving everything else byte-identical.
Traps a naive replace-all falls into:
| Trap | Where |
|---|---|
method Widget.ddd must keep its name (and w.ddd() call sites) |
models.py |
w.ddd() + ddd() + core.ddd(2) — rename 2 of 3 refs in one line |
models.py |
ddd_helper, addd, dddx identifiers |
core/utils/legacy.py |
local variable ddd (not a function) |
utils.py local_shadow |
string literals "ddd", dict key, "ddd mode" |
config.py |
| comments and docstrings mentioning ddd | core/report/utils.py |
| README.md mentions ddd and must not be touched | README.md |
Ground truth: fixtures/expected/ (both versions pass python main.py → OK).
Grading (checker.py): per-file text equality (trailing whitespace normalized)
- behavior check.
passrequires 8/8 files AND behavior OK.
Configs
Each config restricts the editing toolset via disabled_tools; shell/process,
web, delegation, review tools are disabled in EVERY config so the agent cannot
bypass the editing tool under test (e.g. sed via run_command).
OUROBOROS_TASK_REVIEW_MODE=off, OUROBOROS_SAFETY_MODE=off keep token counts
about editing work only.
write_file_only,edit_text_only,apply_patch_only,edit_batch_only— exactly one editing tool available (edit_sketch_onlywas removed with the tool; its rows below are historical data, not a config you can run today);default— the historical toolset (write_file + edit_text);full— all editing tools available (agent's free choice).
Run
python devtools/benchmarks/editbench/run_editbench.py --configs all --runs 3 --parallel 2
Spawns one isolated Ouroboros server from the CURRENT working tree (clone +
git diff HEAD + untracked files), submits tasks over HTTP, mines
llm_usage rows (events.jsonl, deduped by ledger attempt ids) and tool_call
rows (tools.jsonl) from the isolated data root. Results: runs.json +
summary.json (see --out).
Results (2026-08-02, main model x-ai/grok-4.5, 3 runs/config)
All configs passed 8/8 files + behavior on every run — the traps did not catch the main model regardless of tool. The discriminating signals were mechanism overhead and format robustness.
| config | pass | avg wall s | avg completion tok | avg LLM calls | edit calls (3 runs) | edit errors |
|---|---|---|---|---|---|---|
| edit_batch_only | 3/3 | 66.3 | 2432 | 5.0 | 3 (1/run) | 0 |
| edit_text_only | 3/3 | 64.3 | 2542 | 5.7 | 19 | 0 |
| write_file_only | 3/3 | 69.3 | 2594 | 5.0 | 18 | 0 |
| apply_patch_only (v2) | 3/3 | 70.4 | 2438 | 6.7 | 10 | 0 |
| edit_sketch_only (v2) | 3/3 | 67.3 | 2351 | 5.0 | 18 (6/run) | 0 |
| default (write_file+edit_text) | 3/3 | 75.3 | 2541 | 5.0 | 18 edit_text | 0 |
| full (free choice) | 3/3 | 75.3 | 2542 | 5.7 | 13 edit_text + 1 edit_batch | 0 |
v1 (before robustness fixes) recorded the two failure modes the fixes removed:
- apply_patch v1: 3 errors / 11 calls — the model decorated headers as
*** Update File: x ***; the parser now tolerates trailing asterisks. - edit_sketch v1: 18 errors / 36 calls, 123 s and 6036 completion tokens per
run — on these tiny files the model naturally sends complete-file sketches
without
... existing code ...markers, which were rejected. Marker-less sketches are now verified and written directly (no apply model call).
Takeaways: edit_batch (counted replace-all) finishes the whole task in ONE
tool call; format-tolerant parsing matters more than clever matching for
apply_patch; the free-choice agent prefers edit_text, so tool descriptions are
the steering surface if a different mix is desired.
v2: real-file tasks (fixtures_v2, generated by make_fixtures_v2.py)
| task | file(s) | shape |
|---|---|---|
| t2_surgical | review_state.py (1691 lines) | 2 helper renames + 2 constant bumps = 11 changed lines |
| t3_blocks | shell_parse.py (460 lines) | replace 3 whole functions with code given verbatim in the prompt |
| t4_move | shell_parse.py + git_shell_policy.py | move a function across files (delete + insert renamed + fix import/call site) |
| t5_overhaul | provider_models.py (423 lines) | flip 208 double-quoted literals to single quotes (119 lines change, AST-identical) |
v2 results (2026-08-02, grok-4.5, 3 runs/config, 63/63 pass)
Avg per run; errors are failed edit-tool calls across the 3 runs.
| task | config | wall s | completion tok | cost $ | edit calls | errors |
|---|---|---|---|---|---|---|
| t2_surgical | apply_patch | 57 | 1427 | 0.129 | 1.3 | 0 |
| t2_surgical | edit_batch | 54 | 1180 | 0.140 | 1.3 | 1 |
| t2_surgical | edit_text | 59 | 1856 | 0.181 | 9.7 | 0 |
| t2_surgical | edit_sketch | 229 | 20972 | 0.352 | 1 | 0 |
| t2_surgical | full → chose edit_batch | 48 | 1065 | 0.174 | 1 | 0 |
| t3_blocks | apply_patch | 47 | 1208 | 0.115 | 1 | 0 |
| t3_blocks | edit_text | 52 | 1536 | 0.122 | 3 | 0 |
| t3_blocks | edit_sketch | 99 | 6549 | 0.155 | 1 | 0 |
| t3_blocks | full → chose edit_text | 53 | 1372 | 0.117 | 2 | 0 |
| t4_move | edit_text | 63 | 2519 | 0.110 | 4 | 0 |
| t4_move | apply_patch | 62 | 2395 | 0.131 | 1 | 0 |
| t4_move | edit_sketch | 161 | 12107 | 0.232 | 2 | 0 |
| t4_move | full → chose edit_text | 62 | 2584 | 0.121 | 4 | 0 |
| t5_overhaul | edit_sketch (marker-less → direct write) | 125 | 6965 | 0.162 | 1 | 0 |
| t5_overhaul | write_file | 130 | 7364 | 0.196 | 1 | 0 |
| t5_overhaul | edit_text | 166 | 11373 | 0.261 | 8.3 | 0 |
| t5_overhaul | edit_batch | 178 | 11488 | 0.278 | 4.7 | 0 |
| t5_overhaul | apply_patch | 196 | 13344 | 0.309 | 13.3 | 2 |
| t5_overhaul | full → chose write_file | 134 | 8111 | 0.176 | 1 | 0 |
v2 takeaways
- No universal winner — the profile follows task shape. Scattered surgery in a big file: apply_patch/edit_batch (edit_text needs 29 calls and re-reads). Whole-block replacement: edit_text/apply_patch tie. Near-full rewrite: write_file / marker-less edit_sketch; apply_patch is the WORST there (patch ≈ file size split over 40 calls, 491k prompt tokens).
- edit_sketch inverts: worst for surgical edits in big files (the light model regenerates all 1700 lines: 21k completion tokens, 4× wall) — best for near-full rewrites (marker-less sketch = verified direct write).
- The free-choice agent picked the right tool on every task (edit_batch on t2, edit_text on t3/t4, write_file on t5) and was never worst, twice fastest. Tool diversity + honest descriptions is enough; no routing logic needed.
- Correctness was never the discriminator (63/63): the value of the new tools is fewer round trips, fewer retries, and the counted/atomic safety rails.
Weak-model run (google/gemini-3.6-flash, t1+t2, old-vs-new configs, 2 runs each)
Pinned via --model (main lane + fallbacks). 16/16 pass — correctness did not
break even on the weak model at this task scale; the errors that did happen were
all caught atomically by the tools. What changed is the SIZE of the efficiency gap:
| task | config | wall s | prompt tok | completion tok | cost $ | edit calls | errors |
|---|---|---|---|---|---|---|---|
| t1 | edit_batch | 292 | 1.40M | 10.3k | 0.70 | 1 | 0 |
| t1 | apply_patch | 349 | 1.70M | 11.6k | 0.80 | 1.5 | 1 |
| t1 | edit_text | 482 | 2.55M | 11.2k | 1.04 | 11 | 0 |
| t1 | write_file | 540 | 2.68M | 15.8k | 1.12 | 6 | 0 |
| t2 | edit_batch | 89 | 0.56M | 2.7k | 0.44 | 1 | 0 |
| t2 | edit_text | 148 | 1.04M | 4.1k | 0.58 | 9 | 0 |
| t2 | apply_patch | 229 | 1.28M | 11.0k | 0.68 | 3 | 4 |
| t2 | write_file | 224 | 1.23M | 38.5k | 0.96 | 1.5 | 0 |
- The old toolset's cost/latency penalty roughly DOUBLES on the weak model: edit_batch is 1.5× cheaper than edit_text and 2.2-2.5× cheaper/faster than write_file (which finally shows the asymptotic completion-token pain: 38k tokens to rewrite 1691 lines — byte-perfect, but slow and expensive).
- apply_patch degrades on the weak model: flash writes sloppier patches (4 format/context errors in one t2 run, all rejected atomically, retried to success). Its value is model-quality-dependent; edit_batch is robust everywhere.
- Flash is ~7× more expensive per run than grok-4.5 here despite cheaper tokens — weak models iterate far more (38-66 LLM calls vs 5) and cache poorly.
Caveats
- Toy files are ~20 lines, so
write_file(full rewrite) is barely penalized on completion tokens; on real-sized files the gap grows with file size. The benchmark discriminates mechanism overhead (round trips, retries, error rates), not asymptotic token cost. - Prompt tokens are dominated by the system prompt (~30k/call, mostly prompt-cached); the discriminating signals are completion tokens, LLM call count, wall time, and edit-tool error counts.
- One grading run == one agent task;
--runs 3per config for variance.