File Editing

Precise, conflict-safe find-and-replace across files, safe by default against concurrent edits

File editing tools provide secure, workspace-scoped file operations with precise, conflict-safe find-and-replace that’s safe by default. Ambiguous matches fail loudly instead of editing the wrong occurrence. Read-modify-write on the same file is automatically serialised across parallel tools and multi-agent teams, and writes abort with a clear error if the file changed on disk since it was last read — no extra parameters required. A fuzzy matching ladder makes first-try edits succeed even when old_string drifts from the file by whitespace, indentation, or line endings.

python
from praisonaiagents import Agent

agent = Agent(
    name="coder",
    instructions="Edit files with safe find-and-replace tools",
)

agent.start("Rename the helper in src/utils.py")

The user requests a code change; the agent reads the file, matches uniquely, verifies the hash, then writes and returns a diff.

Diagram

Safe-by-Default Edit Flow

Yes

No

Yes

No

🤖 Agent

📖 read_file

#️⃣ record hash

✏️ edit_file

🔒 per-file lock

🔍 Match unique?

🛡️ Hash unchanged?

⚠️ Ambiguous match

💾 Preserve LF/CRLF/BOM

⚠️ File changed — re-read or force=True

📋 Return diff

🩺 Post-edit diagnostics

The user describes an edit; the agent reads, matches uniquely, and writes with conflict checks.

Quick Start

  1. Agent edits a file safely (no boilerplate)
    python
    from praisonaiagents import Agent
    
    agent = Agent(
        name="Code Editor",
        instructions="Edit code precisely. Re-read files before retrying after a stale error.",
        tools=["read_file", "edit_file", "search_files"],
    )
    
    agent.start("In src/user.js, replace getUserName() with getUserEmail().")
    # read_file records a hash; edit_file is auto-serialised and stale-aborted
    # with no extra params from the agent.
  2. String-name resolution — no import needed
    python
    from praisonaiagents import Agent
    
    agent = Agent(name="coder", tools=["edit_file", "apply_patch", "read_file"])
    agent.start("Refactor UserService to AccountService in src/user.py")
  3. Direct SDK use (automatic protection)
    python
    from praisonaiagents.tools.edit_tools import read_file, edit_file
    
    read_file("src/user.js")  # arms the automatic staleness guard
    
    edit_file(
        "src/user.js",
        old_string="def getUserName(self):",
        new_string="def getUserEmail(self):",
    )
    # Aborts with "file changed since it was read" if anything modifies the file
    # between the read and the edit. No expected_hash needed.
  4. Replace all occurrences
    python
    from praisonaiagents.tools.edit_tools import edit_file
    
    edit_file(
        "styles.css",
        old_string="color: blue",
        new_string="color: green",
        replace_all=True,
    )

How It Works

Diagram
✏️ edit_file📄 File📁 Workspace🗂️ Registry📖 read_file🤖 Agent✏️ edit_file📄 File📁 Workspace🗂️ Registry📖 read_file🤖 Agentautomatic staleness checkread_file(path)validate containment✅ allowedread bytesrecord hashcontentedit_file(path, old, new)re-read + hashget recorded hashfuzzy ladder + ambiguity checkwrite preserved LF/CRLF/BOMsuccess + unified diff
OperationRisk LevelWorkspace RequiredPurpose
search_filesLowNoFind patterns in files
read_file (edit_tools)LowNoRead content + SHA-256 hash; records hash into the shared staleness registry
read_file (file_tools)LowNoPlain read; also records hash so a later write_file engages the staleness guard
list_filesLowNoDirectory listings
edit_fileHighRecommendedPrecise find-and-replace; automatic per-file lock + staleness guard; force=True to override
write_fileHighRecommendedCreate/overwrite; automatic per-file lock + staleness guard on existing files; force=True to override
apply_patchHighRecommendedAtomic multi-file Add/Update/Delete/Move with rollback; per-path locks across the whole patch (both endpoints of a Move); staleness guard on Update, Delete and Move; force=True to override

Safe by Default: Concurrency & Staleness

Every read-modify-write on the same file is serialised by a per-file lock, and every write checks whether the file changed since it was last read — both run automatically across FileTools, EditTools, and apply_patch.

Per-File Locking

Diagram
graph TB
    subgraph "Per-File Lock — same path serialises, different paths parallel"
        A1[Agent A: edit_file file.py] --> L1[🔒 lock(file.py)]
        A2[Agent B: edit_file file.py] -.waits.-> L1
        A3[Agent C: edit_file other.py] --> L2[🔒 lock(other.py)]
        L1 --> W1[✏️ A writes]
        W1 --> R1[🔓 release]
        R1 --> A2W[✏️ B writes]
        L2 --> W3[✏️ C writes in parallel]
    end

    classDef agent fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef lock fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef ok fill:#10B981,stroke:#7C90A0,color:#fff

    class A1,A2,A3 agent
    class L1,L2 lock
    class W1,A2W,W3,R1 ok

Automatic Staleness Guard

Diagram
📄 File✏️ edit_file🌐 External writer🗂️ Registry📖 read_file🤖 Agent📄 File✏️ edit_file🌐 External writer🗂️ Registry📖 read_file🤖 Agentrecorded ≠ currentread_file(path)read bytesrecord hashcontentexternal changeedit_file(path, old, new)re-read + hashget recorded hash⚠️ Error: file changed — re-read or force=True

Which Override Do I Need?

Diagram

Yes — I want the new content

No — I want to overwrite anyway

Yes

No

Edit is failing with a staleness error

Did the file legitimately change?

Re-read, then edit again

Do I have a known-good hash?

Pass expected_hash=hash

Pass force=True

Precedence Ladder

MechanismWhen it firesHow to bypass
Explicit expected_hashAlways when suppliedPass a matching hash, or omit it and rely on the automatic guard
Automatic staleness guardOnly when a prior read_file recorded a hash for the pathforce=True (or pass expected_hash explicitly)
Per-file lockAlways (cannot be bypassed)n/a — serialises but never refuses

The precedence order is: expected_hash > force=True > automatic last-read-hash guard.

force=True Examples

python
from praisonaiagents.tools.edit_tools import edit_file, apply_patch
from praisonaiagents.tools.file_tools import FileTools

# 1. Intentional blind edit
edit_file("rollback.txt", "v2", "v1", force=True)

# 2. Force an entire patch (overrides Update, Delete & Move staleness; per-file lock still applies)
apply_patch(patch_text, force=True)

# 3. Force a blind write through FileTools
FileTools().write_file("out.log", "snapshot", force=True)

How Fuzzy Matching Works

edit_file walks a deterministic ladder of matching strategies and stops at the first strategy that produces a confident match. Exact matches always win; fuzzy strategies only engage when an exact substring is not found. Existing code keeps behaving exactly as before — only previously-failing edits now succeed.

Diagram

Fuzzy Matching Ladder

match

miss

match

miss

match

miss

match

miss

match

miss

yes

no

🤖 Agent old_string

1. exact

✅ Apply

2. line_trimmed
3. whitespace_normalised
4. indentation_flexible
5. block_anchor + similarity 0.7

⚠️ String not found + closest-match hint

Single span?

💾 Write + diff

⚠️ Ambiguous match

#StrategyToleratesExample divergence
1exactnothingbyte-for-byte match
2line_trimmedleading/trailing whitespace per line return 1 vs return 1
3whitespace_normalisedcollapsed internal whitespacex = 1 vs x = 1
4indentation_flexibletabs vs spaces, depth\treturn 1 vs return 1
5block_anchorstructural drift (similarity ≥ 0.7)first/last lines anchor a fuzzy block

Confidence guards (block_anchor only):

  • Similarity threshold: 0.7 (constant _BLOCK_ANCHOR_THRESHOLD in source)
  • Disproportionate-length guard: rejects blocks more than 2× or less than half the old_string line count
  • Tie-breaking: two equally-scored candidates are treated as ambiguous (not silently picked)
python
from praisonaiagents.tools.edit_tools import edit_file

# File uses tabs; old_string uses spaces — still succeeds
# because indentation_flexible normalises both
edit_file(
    "src/utils.py",
    old_string="    return value",
    new_string="    return processed_value",
)

Self-Correcting No-Match Hints

When the fuzzy ladder finds nothing, the error shows the most similar lines of the file so the agent can fix old_string and retry without re-reading.

Diagram

No-Match Hint Flow

no match

yes

no

🤖 Agent old_string

🔎 Fuzzy ladder

📏 Similar region ≥ 0.6?

📍 Error + numbered lines

⚠️ Plain not-found error

🤖 Agent retries

Agent Quick Start

python
from praisonaiagents import Agent

agent = Agent(
    name="Self-Correcting Editor",
    instructions=(
        "Edit files precisely. If a 'String not found' error includes a "
        "'Closest match in the file' block, copy old_string from those lines "
        "and retry."
    ),
    tools=["read_file", "edit_file"],
)

agent.start("In src/compute.py, change `return x * 2` to `return x * 3`.")

How it plays out:

  1. The agent sends an old_string that is slightly off (for example def compute(y): instead of def compute(x):).
  2. edit_file fails and shows the closest lines with line numbers.
  3. The agent copies those lines into old_string and retries — no extra read_file call.

What the hint looks like

text
Error: String not found in file: 'def compute(y):...'. Closest match in the file, lines 3-4:
    3| def compute(x):
    4|     total = x * 2
Adjust old_string to match these lines exactly, or re-read the file if it changed.

apply_patch Update and Move hunks use the same hint after Error: Hunk not found in '<path>': '<preview>'.

When the hint appears

ConditionHint shown?
A region of the file reaches similarity ≥ 0.6✅ Yes
Nothing in the file is similar enough❌ No — plain not-found error
old_string longer than 2000 characters❌ No — skipped to keep it fast
File longer than 20000 lines❌ No — skipped to keep it fast
Search budget runs out✅ Best match found so far

The hint shows up to 10 lines (with ... if the region is longer), and each line is cut to 200 characters with ... [truncated]. Success and ambiguous-match messages are unchanged.


Multi-file Patches with apply_patch

apply_patch lets an agent Add, Update, Delete, and Move/rename multiple files in a single atomic call — all changes succeed together or none are committed.

Diagram

Yes, one change

Yes, multiple changes

Multiple files

Rename with or without edits

Need to edit files?

One file?

edit_file

apply_patch with one Update

apply_patch

apply_patch with *** Move File

All-or-nothing atomic

  1. Agent Quick Start
    python
    from praisonaiagents import Agent
    
    agent = Agent(
        name="Refactor Agent",
        instructions="Refactor across files atomically. Use apply_patch for multi-file changes.",
        tools=["read_file", "search_files", "apply_patch", "edit_file"],
    )
    
    agent.start("Rename UserService to AccountService across src/ and update its tests.")
  2. Direct SDK use
    python
    from praisonaiagents.tools.edit_tools import apply_patch
    
    patch = """*** Update File: src/service.py
    @@
    class UserService:
    ===
    class AccountService:
    *** Update File: tests/test_service.py
    @@
    from src.service import UserService
    ===
    from src.service import AccountService
    *** Delete File: docs/old_userservice.md
    """
    
    result = apply_patch(patch)
    print(result)  # "Success: Applied patch to 3 file(s) ... <combined diff>"

Patch Format Reference

HeaderBody formatPurpose
*** Add File: <path>Full file content lines until next headerCreate a new file (errors if path already exists)
*** Update File: <path>One or more @@ hunks (<old>\n===\n<new>)Modify file using fuzzy ladder for each hunk
*** Delete File: <path>(no body)Remove file (errors if path missing)
*** Move File: <old> -> <new>Optional @@ hunks (<old>\n===\n<new>)Rename a file (and optionally edit it in the same atomic op); errors if source missing or destination exists

Optional sentinels Begin Patch / End Patch are accepted and stripped.

The Move File split is on the literal " -> " (space on both sides), so paths containing -> without surrounding spaces are unaffected. Both source and destination must be non-empty and resolve to different paths.

Update hunk syntax:

*** Update File: path/to/file
@@
<old block to find>
===
<new block to replace it with>
@@
<another old block>
===
<another new block>

Each @@ hunk runs through the same fuzzy ladder as edit_file, so whitespace/indentation drift in the old block is tolerated.

Move hunk syntax:

Rename only — the header stands on its own line:

*** Move File: src/user.py -> src/account.py

Rename and edit in one atomic op — add one or more @@ hunks after the header:

*** Move File: src/user.py -> src/account.py
@@
class User:
===
class Account:

Move hunks run through the same fuzzy ladder as Update, and BOM/CRLF are preserved from the source.

  1. Agent rename-and-edit in one op
    python
    from praisonaiagents import Agent
    
    agent = Agent(
        name="Refactor Agent",
        instructions="Rename files with apply_patch's Move File; combine with @@ hunks to edit in the same op.",
        tools=["read_file", "apply_patch", "edit_file"],
    )
    
    agent.start("Rename src/user.py to src/account.py and swap `class User` for `class Account`.")
  2. Direct SDK use
    python
    from praisonaiagents.tools.edit_tools import apply_patch
    
    result = apply_patch("""*** Move File: src/user.py -> src/account.py
    @@
    class User:
    ===
    class Account:
    """)
    print(result)  # "Renamed src/user.py -> src/account.py\n<diff>"

Atomicity Guarantees

Diagram
📁 Filesystem✏️ apply_patch🤖 Agent📁 Filesystem✏️ apply_patch🤖 AgentCompute new content, check existence,resolve fuzzy hunksalt[All commits succeed][Any commit fails]apply_patch(patch_text)Phase 1 — parse + validatePhase 2 — commit (staged temp files)✅ Each os.replace OKSuccess + combined diffRoll back (LIFO restore backups)RestoredError: …
BehaviourHow it works
All-or-nothingPhase 1 validates every operation and computes new content; Phase 2 commits with staged temp files and os.replace
Rollback on failureIf any commit step raises, applied operations are reversed in LIFO order via backup paths
BOM preservationUTF-8 BOM detected on Update is reapplied on write
Line-ending preservationCRLF files stay CRLF, LF files stay LF (matches edit_file)
UTF-16 rejectionUpdate on a UTF-16 file fails with a clear error
Move is atomicThe destination is written first, then the source renamed aside — either both land or neither does, and the source is restored on any later failure
Move preserves BOM / line endingsSame as Update — UTF-8 BOM and CRLF/LF are carried over from the source
Cross-op destination conflicts fail up frontTwo operations producing the same destination are rejected in validate; nothing is committed
Staging never clobbers a siblingA pre-existing file at the default .praison_patch_tmp staging name is stepped around instead of truncated or deleted

Move commit flow:

Diagram
📁 Filesystem✏️ apply_patch📁 Filesystem✏️ apply_patchalt[Both steps succeed][Later failure]Write destination (staged temp → os.replace)Rename source aside (backup)✅ Renamed src -> dstDrop destinationRestore source + re-arm staleness guardRolled back

apply_patch Error Messages

TriggerMessage
Empty / no operationsError: Patch contains no operations
Malformed header / orphan bodyError: Invalid patch: Unexpected line in patch (expected a section header): ...
Add target already existsError: Cannot add '<path>': file already exists
Delete target missingError: Cannot delete '<path>': file not found
Update target missingError: Cannot update '<path>': file not found
Empty hunk old-blockError: Empty hunk in update for '<path>'
Hunk not found (nothing similar)Error: Hunk not found in '<path>': '<preview>'
Hunk not found (close match exists)Error: Hunk not found in '<path>': '<preview>'. Closest match in the file, lines {start}-{end}: + numbered lines + Adjust old_string to match these lines exactly, or re-read the file if it changed.
Ambiguous hunkError: Ambiguous hunk in '<path>': '<preview>' matches N locations
UTF-16 fileError: Cannot update '<path>': UTF-16 encoding is not supported. Please convert the file to UTF-8.
Automatic staleness — UpdateError: Cannot update '{path}': file changed since it was read - re-read before editing (or pass force=True to override).
Automatic staleness — DeleteError: Cannot delete '{path}': file changed since it was read - re-read before editing (or pass force=True to override).
Malformed Move File header (no -> )Error: Invalid patch: Malformed Move File header (expected '<old> -> <new>'): '<payload>'
Empty source or destination in Move File headerError: Invalid patch: Malformed Move File header (empty source or destination): '<payload>'
Move source and destination are the sameError: Cannot move '<src>' to '<dst>': source and destination are the same path
Move source missingError: Cannot move '<src>': file not found
Move destination already existsError: Cannot move '<src>' to '<dst>': destination already exists
Empty hunk old-block in a MoveError: Empty hunk in move for '<src>'
Hunk not found in a Move (nothing similar)Error: Hunk not found in '<src>': '<preview>'
Hunk not found in a Move (close match exists)Error: Hunk not found in '<src>': '<preview>'. Closest match in the file, lines {start}-{end}: + numbered lines + Adjust old_string to match these lines exactly, or re-read the file if it changed.
Ambiguous hunk in a MoveError: Ambiguous hunk in '<src>': '<preview>' matches N locations
Cross-op conflict on same destinationError: Conflicting operations target the same path '<dst>' (also produced by '<earlier op display>')
Automatic staleness — MoveError: Cannot move '<src>': file changed since it was read - re-read before editing (or pass force=True to override).
Undecodable source on MoveError: Cannot move '<src>': <decode error>
Move success lineRenamed <src> -> <dst>\n<diff><diagnostics>
SuccessSuccess: Applied patch to N file(s)\n\n<combined diff>

Post-edit Diagnostics

After a successful edit, edit_file and apply_patch can run a lightweight checker on the modified file and append concise diagnostics to the tool result — so the agent sees the problem and fixes it in the same loop.

Diagram

Edit → Verify → Fix (single loop)

Yes

No

Yes

🤖 Agent

✏️ edit_file

✅ Success?

🩺 Run checker

Problems?

Plain success

Success + Diagnostics block

🤖 Agent re-edits

Agent Quick Start

python
from praisonaiagents import Agent

agent = Agent(
    name="Self-correcting Editor",
    instructions=(
        "Edit code precisely. If a Diagnostics section appears in the tool "
        "result, fix the reported problems in your next edit."
    ),
    tools=["edit_file", "apply_patch", "read_file"],
)

agent.start("In src/calc.py, replace `total = a + b` with `total = a + b + c`.")

When a Diagnostics block appears, the agent self-corrects in the next turn — no separate “run the linter” task required.

Three modes

Diagram

Yes

No

Yes

No

Which mode?

Want backward-compatible
silent-on-clean output?

auto — default

Want diagnostics
even for clean edits?

on

off — zero overhead

ModeWhen the Diagnostics block appearsUse when
"auto" (default)Only when the checker reports problemsYou want zero noise on clean edits and a self-correction hint on broken ones
"on"Always when a checker is available (shows no problems found on clean)You want positive confirmation in logs/traces
"off"NeverYou’re in a sandbox with no checkers, or want absolute minimum overhead

Configure the mode

python
from praisonaiagents.tools.edit_tools import create_edit_tools

edit_tools = create_edit_tools(post_edit_diagnostics="on")  # or "auto" / "off"

Which checker runs?

The first installed candidate for the file extension is used. Nothing installed → diagnostics are silently skipped and the tool returns the plain success string.

ExtensionCheckers (in order)
.py, .pyiruff → py_compile (stdlib, always available)
.ts, .tsx, .js, .jsx, .mjs, .cjseslint → tsc (TS only)
.jsonstdlib JSON parser
anything else(no checker — silent)

Output format

Success: Made 1 replacement(s) in src/calc.py

Diff:
@@ -1 +1 @@
-total = a + b
+total = a + b + c

Diagnostics (ruff):
src/calc.py:1:18: F821 Undefined name `c`

Guarantees

  • Backward compatible — auto mode keeps the success string unchanged when the edit is clean.
  • Never breaks an edit — a missing, slow, or crashing checker is silently skipped (logged at DEBUG).
  • Bounded — 10-second timeout per file; output capped at 2000 characters and truncated cleanly beyond that.
  • Sandbox-safe — eslint runs with --no-config-lookup so workspace-controlled configs/plugins cannot execute code; tsc runs per-file without a tsconfig.json.
  • Zero overhead in off mode — all imports are deferred.

Post-edit Formatting

After a successful edit, EditTools can run a configured language-appropriate formatter on the touched file and persist the formatted result. Default is "off" — byte-for-byte identical to prior behaviour. A missing or failing formatter never turns a successful edit into a failure.

Diagram

yes

off

auto/on

✏️ edit_file / apply_patch

✅ edit OK?

🎨 post_edit_format?

💾 file saved

🔍 detect formatter

⚙️ run formatter

🔁 refresh hash

Agent Quick Start

python
from praisonaiagents import Agent
from praisonaiagents.tools.edit_tools import create_edit_tools

edit_tools = create_edit_tools(post_edit_format="auto")

agent = Agent(
    name="Code Editor",
    instructions="Edit files cleanly and let the formatter tidy up afterward.",
    tools=[edit_tools.edit_file, edit_tools.apply_patch],
)

agent.start("Refactor src/utils.py to use list comprehensions")

Three modes

ModeBehaviourUse when
"off" (default)Never runs a formatterBackward-compatible zero overhead
"auto"Run a formatter if one is detected for the file type, otherwise skip silentlyTypical projects with formatters installed
"on"Same as "auto" — run when detectedExplicit opt-in

Invalid values silently fall back to "off".

Built-in formatters

Extension(s)Formatter (preference order)Notes
.py, .pyiruff format → blackFirst on PATH wins
.ts, .tsx, .js, .jsx, .mjs, .cjs, .json, .css, .scss, .less, .html, .md, .yaml, .ymlprettierRuns with --no-config --no-editorconfig
.gogofmt
.rsrustfmt

Relative commands (e.g. ./node_modules/.bin/prettier) resolve relative to the edited file’s directory, not CWD. Formatter runs under a 10-second timeout; timeouts are swallowed. The on-disk hash baseline is only refreshed when the formatter exits 0.

Custom formatter overrides

python
from praisonaiagents.tools.edit_tools import create_edit_tools

edit_tools = create_edit_tools(
    post_edit_format="auto",
    formatters={
        ".py": ("black", ["black", "--quiet"]),
        ".ts": ("local-prettier", ["./node_modules/.bin/prettier", "--write", "{path}"]),
    },
)

formatters maps lowercase extensions to (tool_name, argv_template). Use {path} in argv entries; if omitted, the path is appended.


Configuration Options

File Editing Functions

FunctionArgsReturnsNotes
edit_filefilepath, old_string, new_string, replace_all=False, expected_hash=None, force=FalsestrHigh-risk; fuzzy ladder + fails on ambiguous match unless replace_all=True; automatic per-file lock + staleness guard
apply_patchpatch: str, force=FalsestrHigh-risk; atomic multi-file Add/Update/Delete/Move with rollback; per-path locks (both endpoints of a Move) + staleness guard on Update, Delete and Move
read_file (edit_tools)filepathTuple[str, str](content, sha256_hex); records hash into shared registry
read_file (file_tools)filepath, encoding='utf-8'strSimple read; also records hash into shared registry
search_filesdirectory, pattern, file_pattern='*'JSON stringCase-insensitive substring search
write_filefilepath, content, encoding='utf-8', force=FalseboolFull overwrite; automatic per-file lock + staleness guard on existing files
list_filesdirectorylist[dict]Directory listing

Edit Parameters

ParameterTypeDefaultPurpose
replace_allboolFalseRequired when old_string matches more than once
expected_hashOptional[str]NoneSHA-256 hex digest from the last read_file; aborts if the file changed (takes priority over the automatic guard)
forceboolFalseBypass the automatic staleness guard for an intentional blind write. An explicit expected_hash is still honoured even when force=True.

Constructor Parameters (EditTools / create_edit_tools)

ParameterTypeDefaultPurpose
post_edit_diagnosticsstr ("auto" \"on" \"off")"auto"Run a per-file checker after a successful edit and append concise diagnostics. See Post-edit Diagnostics. Invalid values fall back to "auto".
post_edit_formatstr ("off" \"auto" \"on")"off"Run a language-appropriate formatter after a successful edit and persist the result. See Post-edit Formatting. Invalid values fall back to "off".
formattersOptional[dict]NoneOverride map: extension → (tool_name, argv_template). See Post-edit Formatting.
rootOptional[str]None (current working directory at construction time)Base directory that confines all path resolution when no workspace= is supplied. Any path resolving outside this base is rejected with Path traversal detected: <input> escapes workspace <root>. Pinned at construction so a later os.chdir() never moves the boundary. Ignored when workspace= is set.

Confining the no-workspace fallback

Pin the fallback base directory even when no Workspace object is wired up.

python
from praisonaiagents.tools.edit_tools import create_edit_tools

# Pin the fallback base to a specific project directory.
edit_tools = create_edit_tools(root="/srv/projects/demo")

# Reads inside the root work; anything outside is rejected.
content, meta = edit_tools.read_file("/srv/projects/demo/README.md")   # ok
content, meta = edit_tools.read_file("/etc/passwd")                    # Path traversal detected
python
# Single unique match — guard fires automatically after a prior read
edit_file("config.py", "DEBUG = False", "DEBUG = True")

# Multiple matches — replace_all required
edit_file("styles.css", "color: blue", "color: green", replace_all=True)

# Explicit hash (most precise — takes priority over automatic guard)
content, h = read_file("config.py")
edit_file("config.py", "DEBUG = False", "DEBUG = True", expected_hash=h)

# Intentional blind write — skip the staleness check
edit_file("rollback.txt", "v2", "v1", force=True)

Error Messages

TriggerMessage
File not foundError: File not found: {filepath}
UTF-16 encodingError: UTF-16 encoding is not supported. Please convert the file to UTF-8.
Explicit expected_hash mismatchError: File has been modified since last read. Please re-read the file before editing. Expected hash: {expected_hash[:8]}..., Current hash: {current_hash[:8]}...
Automatic staleness — edit_fileError: File changed since it was read - re-read before editing (or pass force=True to override). Recorded hash: {recorded[:8]}..., Current hash: {current[:8]}...
Automatic staleness — apply_patch UpdateError: Cannot update '{path}': file changed since it was read - re-read before editing (or pass force=True to override).
Automatic staleness — apply_patch DeleteError: Cannot delete '{path}': file changed since it was read - re-read before editing (or pass force=True to override).
write_file stale refusal(logger error; tool returns False) Refusing to write {filepath}: file changed since it was read - re-read before writing (or pass force=True)
write_file verify-read failure(logger error; tool returns False) Refusing to write {filepath}: could not verify staleness: {err}
Empty old_stringError: old_string must be non-empty
String not found (nothing similar)Error: String not found in file: '{preview}'
String not found (close match exists)Error: String not found in file: '{preview}'. Closest match in the file, lines {start}-{end}: + numbered lines + Adjust old_string to match these lines exactly, or re-read the file if it changed.
Ambiguous matchError: Ambiguous match - '{preview}' occurs {N} times. Please provide more surrounding context to make the match unique, or use replace_all=True to replace all occurrences.
SuccessSuccess: Made {N} replacement(s) in {filepath}\n\nDiff:\n{diff}

Common Patterns

Code Refactoring

python
from praisonaiagents.tools.edit_tools import read_file, edit_file

read_file("src/utils.js")
edit_file(
    "src/utils.js",
    "function oldFunction(",
    "function newFunction(",
)

Configuration Updates

python
# Ambiguous if port: 3000 appears twice — add context or use replace_all=True
edit_file(
    "config.js",
    "server: {\n  port: 3000",
    "server: {\n  port: 8080",
)

Surviving Concurrent Edits

python
from praisonaiagents.tools.edit_tools import read_file, edit_file

# Automatic — the recorded read hash makes the edit safe.
read_file("config.py")
result = edit_file("config.py", "DEBUG = False", "DEBUG = True")
# If anything changed config.py between the read and the edit, the result is
# the staleness error; re-read and retry, or pass force=True for an intentional
# blind write.

# Same protection for write_file:
from praisonaiagents.tools.file_tools import FileTools
ft = FileTools()
ft.read_file("config.json")
ft.write_file("config.json", '{"debug": true}')  # refuses if config.json moved on us

If you already have a trusted hash:

python
from praisonaiagents.tools.edit_tools import read_file, edit_file

content, h = read_file("config.py")
# ... another process may edit the file here ...
result = edit_file("config.py", "DEBUG = False", "DEBUG = True", expected_hash=h)
# Returns stale-file error if content changed — re-read and retry

Atomic Multi-file Rename

Move the file and fix its importer in one lossless, atomic op — no need to restate the file body:

python
from praisonaiagents.tools.edit_tools import apply_patch

result = apply_patch("""*** Begin Patch
*** Move File: src/auth.py -> src/account.py
@@
class UserService:
===
class AccountService:
*** Update File: tests/test_auth.py
@@
from src.auth import UserService
===
from src.account import AccountService
*** End Patch
""")

Without Move File, the same rename needs a Delete + Add pair that restates the whole file body — heavier and not lossless.


Best Practices

Read before you edit — the guard is automatic

Any read_file call (via edit_tools or file_tools) arms the automatic staleness guard for the next write or edit on that path. The guard fires without any extra parameters; just read first and the protection is in place.

Use force=True only for intentional blind overwrites

force=True bypasses the automatic staleness guard on edit_file, apply_patch, and write_file — but the per-file lock still applies. It’s not a “skip all safety” flag; it only allows the write to proceed when the file changed since the last read. Use it for deliberate rollbacks or idempotent snapshots, not as a routine escape from proper read-before-write discipline.

Same path = same lock, different paths = parallel

The per-file lock is keyed on the canonical path, so two callers that resolve to the same file always serialise — regardless of which module or agent they use. Different files proceed in parallel, so unrelated edits in a multi-agent run are never unnecessarily blocked.

Search Before Edit

Use search_files to locate patterns before editing so you know scope and can craft a unique old_string.

Use the returned diff for verification

edit_file returns a bounded unified diff (10 lines max, 200 chars per line) so you rarely need a second read.

Make old_string unique

Include surrounding context so the match is unambiguous; use replace_all=True only when every occurrence should change.

If the first attempt returns String not found, the error usually includes the closest lines with line numbers, so the next old_string is a small adjustment.

Line endings and BOM are preserved

CRLF files stay CRLF, LF files stay LF, UTF-8 BOM is preserved. UTF-16 files are rejected with a clear error.

Use apply_patch for multi-file changes

Use apply_patch when changes span multiple files and must succeed/fail together (rename, refactor, dependency bump). Use edit_file when changing one file in one place — it returns a focused diff and avoids patch syntax overhead.

Prefer Move File over Delete+Add for renames

*** Move File: <old> -> <new> is atomic, race-safe on both endpoints, and preserves BOM/CRLF — without re-sending the file body. Combine it with @@ hunks to rename and edit in one operation, instead of a heavier Delete + Add pair that restates the whole file.

Patch hunk format is not unified diff

The patch hunk format uses @@ to separate hunks and === to separate old from new — this is not unified diff format. Add/Delete sections must not contain @@/=== markers.

Read the closest-match hint before re-reading the file

When edit_file returns String not found with a Closest match in the file, lines X-Y: block, those lines are the file’s current contents. Adjust old_string to match and retry — use read_file only when the hint is missing or points at the wrong place.

Workspace Security

File operations respect workspace boundaries. Paths outside the workspace are rejected to prevent directory traversal.

Post-edit diagnostics mode

Leave post_edit_diagnostics at its default ("auto"). When the checker reports problems, the tool result includes a Diagnostics (<tool>): block — instruct the agent to fix the reported issues in its next edit. Switch to "on" if you want positive no problems found confirmations in traces, or "off" if you’re in a sandbox with no checkers available.


Related