editFileTool function

AgentTool editFileTool(
  1. ExecutionEnv env, {
  2. HashlineSnapshotStore? snapshots,
})

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);
      }
    },
  );
}