返回 DeepSeek-Reasonix
multiedit.go
根目录 / internal / tool / builtin / multiedit.go
1 package builtin
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7
8 "reasonix/internal/sandbox"
9 "reasonix/internal/tool"
10 )
11
12 func init() { tool.RegisterBuiltin(multiEdit{}) }
13
14 // multiEdit applies a batch of edits to one file. roots confines the target to
15 // the workspace when non-empty (see writeFile); guard rejects Reasonix
16 // session-data targets (see SessionDataGuard); workDir, when non-empty, is the
17 // directory a relative path resolves against (see resolveIn).
18 type multiEdit struct {
19 roots []string
20 rootSet *sandbox.WritableRootSet
21 guard SessionDataGuard
22 managed ManagedConfigPaths
23 workDir string
24 overlay FileOverlay
25 }
26
27 // editStep is one edit in a multi_edit operation. Mirrors edit_file's args
28 // plus a per-step replace_all toggle so a single call can mix targeted and
29 // sweep replacements (e.g. rename a function with replace_all, then patch
30 // one specific call site with a unique-match edit).
31 type editStep struct {
32 OldString string `json:"old_string"`
33 NewString string `json:"new_string"`
34 ReplaceAll bool `json:"replace_all,omitempty"`
35 }
36
37 func (multiEdit) Name() string { return "multi_edit" }
38
39 func (multiEdit) Description() string {
40 return "Apply a list of edits to a single file atomically: each edit runs against the result of the previous one, all in memory; the file is rewritten only if every edit succeeds. Cheaper and safer than chaining edit_file calls — a failure in step 3 leaves the file untouched instead of half-edited."
41 }
42
43 func (multiEdit) Schema() json.RawMessage {
44 return json.RawMessage(`{
45 "type":"object",
46 "properties":{
47 "path":{"type":"string","description":"File path"},
48 "edits":{
49 "type":"array",
50 "minItems":1,
51 "description":"Ordered edits. Each step sees the file as left by the previous step.",
52 "items":{
53 "type":"object",
54 "properties":{
55 "old_string":{"type":"string","description":"Exact text to find. Without replace_all, must match exactly once."},
56 "new_string":{"type":"string","description":"Replacement text (empty deletes)."},
57 "replace_all":{"type":"boolean","description":"Replace every occurrence instead of requiring uniqueness."}
58 },
59 "required":["old_string","new_string"]
60 }
61 }
62 },
63 "required":["path","edits"]
64 }`)
65 }
66
67 func (multiEdit) ReadOnly() bool { return false }
68
69 func (m multiEdit) DeclareWriteAccess(args json.RawMessage) (tool.WriteAccessDeclaration, error) {
70 return declareFilePathWriteAccess(m.workDir, args)
71 }
72
73 func (m multiEdit) Execute(ctx context.Context, args json.RawMessage) (string, error) {
74 var p struct {
75 Path string `json:"path"`
76 Edits []editStep `json:"edits"`
77 }
78 if err := json.Unmarshal(args, &p); err != nil {
79 return "", fmt.Errorf("invalid args: %w", err)
80 }
81 if p.Path == "" {
82 return "", fmt.Errorf("path is required")
83 }
84 if len(p.Edits) == 0 {
85 return "", fmt.Errorf("edits must not be empty")
86 }
87 p.Path = resolveIn(m.workDir, p.Path)
88 if err := confineWrite(ctx, effectiveWriteRoots(ctx, m.rootSet, m.roots), m.guard, m.managed, p.Path); err != nil {
89 return "", err
90 }
91 unlock := lockMutationPath(p.Path)
92 defer unlock()
93
94 src, err := readEditSource(ctx, m.overlay, p.Path)
95 if err != nil {
96 return "", fmt.Errorf("read %s: %w", p.Path, err)
97 }
98 if err := src.requireObserved(ctx, m.overlay, p.Path); err != nil {
99 return "", err
100 }
101 content := src.content
102
103 // Apply edits in order against the running in-memory buffer. Any failure
104 // returns before the write, leaving the file untouched — that's the
105 // safety guarantee that makes multi_edit preferable to chained
106 // edit_file calls.
107 applied := 0
108 usedFuzzy := false
109 receipts := make([]editReplacementReceipt, 0, len(p.Edits))
110 for i, step := range p.Edits {
111 if step.OldString == "" {
112 return "", fmt.Errorf("edit %d: old_string is required", i+1)
113 }
114 result := applyOldStringEdit(content, step.OldString, step.NewString, step.ReplaceAll)
115 switch {
116 case result.applied > 0:
117 content = result.updated
118 applied += result.applied
119 usedFuzzy = usedFuzzy || result.fuzzy
120 receipts = append(receipts, result.receipt)
121 case result.matches == 0:
122 return "", fmt.Errorf("edit %d: %w", i+1, oldStringNotFoundError(p.Path, step.OldString, content))
123 default:
124 return "", fmt.Errorf("edit %d: %w", i+1, oldStringNotUniqueError(p.Path, step.OldString, content, result.matches, true))
125 }
126 }
127
128 if err := src.write(ctx, m.overlay, p.Path, content); err != nil {
129 return "", fmt.Errorf("write %s: %w", p.Path, err)
130 }
131 summary := fmt.Sprintf("multi_edit %s: %d edits applied (%d total replacements)", p.Path, len(p.Edits), applied)
132 if usedFuzzy {
133 summary += " (fuzzy match)"
134 }
135 return withActualPostWriteReceipts(summary, receipts), nil
136 }
137
137 lines GO