editFileTool function
Creates the edit tool: edits a file in one of two modes.
Legacy exact-match mode (path + oldText + newText): the replacement
only happens when oldText occurs exactly once — the cheap, model-friendly
way to make precise code edits without rewriting whole files (mirrors pi's
edit and Claude Code's str_replace tools).
Hashline mode (patch): a hashline patch with [path#TAG] section
headers and SWAP/DEL/INS ops on 1-indexed line anchors, ported from
oh-my-pi packages/hashline. The tag is a whole-file content hash minted
by a hashline-mode read (or a previous edit response); a stale tag is
rejected BEFORE any write with a diagnostic naming the drifted lines, so
a mistargeted edit can never silently corrupt the file.
snapshots is the session snapshot store binding tags to file content;
share it with the read tool (via builtinTools) so read-minted tags
validate here.
Implementation
AgentTool editFileTool(ExecutionEnv env, {HashlineSnapshotStore? snapshots}) {
final store = snapshots ?? HashlineSnapshotStore();
return AgentTool(
name: 'edit',
label: 'edit',
tier: ApprovalTier.write,
description: editToolDescriptionPrompt,
parameters: const {
'type': 'object',
'properties': {
'path': {
'type': 'string',
'description':
'Path to the file to edit (relative or absolute). Required '
'for exact-match mode; optional in hashline mode (the patch '
'header carries its own [path#TAG]).',
},
'oldText': {
'type': 'string',
'description':
'Exact-match mode: exact text to replace. Must occur exactly '
'once in the file.',
},
'newText': {
'type': 'string',
'description':
'Exact-match mode: replacement text (may be empty to delete '
'oldText).',
},
'patch': {
'type': 'string',
'description':
'Hashline mode: a hashline patch — [path#TAG] section '
'header(s) followed by SWAP/DEL/INS ops anchored on line '
'numbers from a hashline-mode read.',
},
},
},
execute: (arguments, cancelToken, onUpdate) async {
cancelToken?.throwIfCancelled();
final path = arguments['path'] as String?;
final oldText = arguments['oldText'] as String?;
final newText = arguments['newText'] as String?;
final patch = arguments['patch'] as String?;
// Issue #862: an unambiguous both-modes mix is coerced (patch wins;
// a malformed patch with a complete exact-match triple falls back),
// never looped on. Only "neither mode complete" rejects, with a
// remedy example.
final plan = resolveEditMode(
path: path,
oldText: oldText,
newText: newText,
patch: patch,
);
switch (plan) {
case EditReject(:final message):
throw StateError(message);
case EditRunPatch(:final parsed, :final notice):
// The plan carries the ONE shared parse (issue #862 review):
// no re-parse here, so the gate and the apply cannot drift.
// Lock every file the patch can touch: the authored section paths
// AND the canonical paths that minted each cited tag — the
// patcher's missing-path recovery (_recoverSectionPathFromTag) can
// redirect a section onto a snapshot's file, which must not race
// its own mutations either. Extra keys only over-lock briefly;
// sorted acquisition in [runAll] keeps that deadlock-free.
final keys = <String>{
for (final section in parsed.sections)
await _canonicalPath(env, section.path),
for (final section in parsed.sections)
if (section.fileHash != null)
for (final snapshot in store.findByHash(section.fileHash!))
_normalizeLockKey(snapshot.path),
};
final result = await _pathMutationLock.runAll(keys, () {
// Re-check after the lock wait: the queue can span a cancel.
cancelToken?.throwIfCancelled();
return _executeHashlineEdit(env, store, path, parsed, cancelToken);
});
return _withNotice(result, notice);
case EditRunExactMatch(
:final path,
:final oldText,
:final newText,
:final notice,
):
// Issue #1083: hold the lock across the read-validate-write window
// so a concurrent same-file edit applies on top of this one's
// result instead of both editing the same snapshot.
final result = await _pathMutationLock.run(
await _canonicalPath(env, path),
() {
// Re-check after the lock wait: the queue can span a cancel.
cancelToken?.throwIfCancelled();
return _executeExactMatchEdit(
env,
path,
oldText,
newText,
cancelToken,
);
},
);
return _withNotice(result, notice);
}
},
);
}