detached 4463630

fix: guide edit calls to use target file content

commit
4463630d7459838337a5f1a71e0d9142a113cdf0
author
patriceckhart · · 2026-07-11 07:12 UTC
parent
93967bc
signature
unsigned
3 files · +40 -104
M packages/agent/systemprompt.go +1 -1
@@ -86,4 +86,4 @@ const defaultIdentity = `You are an expert coding assistant operating inside zot
8686
8787 Your output renders in a TUI that understands markdown for prose and plain text for tool output. Use markdown freely, keep answers concise, and let tool calls speak for themselves rather than narrating them in prose before you invoke them. Act first, then summarise what you did.
8888
89-When changing file contents, prefer the edit tool for in-place changes and the write tool for creating or fully replacing files. Do not use bash with cat/echo/sed/tee redirections to mutate files; those changes render as opaque shell output while edit renders as a readable diff.`
89+For focused changes to an existing file, inspect its current contents and use edit with verbatim oldText taken from that same file. Include only enough context to make each match unambiguous. Use write when creating a file or replacing it wholesale. Do not mutate files through bash redirections or commands such as cat, echo, sed, or tee, because those changes appear as opaque shell output instead of a readable edit diff.`
@@ -86,4 +86,4 @@ const defaultIdentity = `You are an expert coding assistant operating inside zot
86 86
87 Your output renders in a TUI that understands markdown for prose and plain text for tool output. Use markdown freely, keep answers concise, and let tool calls speak for themselves rather than narrating them in prose before you invoke them. Act first, then summarise what you did.87 Your output renders in a TUI that understands markdown for prose and plain text for tool output. Use markdown freely, keep answers concise, and let tool calls speak for themselves rather than narrating them in prose before you invoke them. Act first, then summarise what you did.
88 88
89-When changing file contents, prefer the edit tool for in-place changes and the write tool for creating or fully replacing files. Do not use bash with cat/echo/sed/tee redirections to mutate files; those changes render as opaque shell output while edit renders as a readable diff.`89+For focused changes to an existing file, inspect its current contents and use edit with verbatim oldText taken from that same file. Include only enough context to make each match unambiguous. Use write when creating a file or replacing it wholesale. Do not mutate files through bash redirections or commands such as cat, echo, sed, or tee, because those changes appear as opaque shell output instead of a readable edit diff.`
M packages/agent/tools/edit.go +16 -27
@@ -28,11 +28,11 @@ type editArgs struct {
2828 Edits []editOp `json:"edits"`
2929 }
3030
31-const editSchema = `{"type":"object","properties":{"path":{"type":"string"},"edits":{"type":"array","items":{"type":"object","properties":{"oldText":{"type":"string"},"newText":{"type":"string"}},"required":["oldText","newText"]}}},"required":["path","edits"]}`
31+const editSchema = `{"type":"object","properties":{"path":{"type":"string","description":"File to modify, given as an absolute path or one relative to the working directory."},"edits":{"type":"array","description":"A set of non-overlapping substitutions, all located in the file content as it existed before this call.","items":{"type":"object","properties":{"oldText":{"type":"string","description":"A verbatim excerpt from the file being modified. Include enough surrounding text to identify exactly one location, without intersecting another edit."},"newText":{"type":"string","description":"Content that will replace the matched excerpt."}},"required":["oldText","newText"]}}},"required":["path","edits"]}`
3232
3333 func (t *EditTool) Name() string { return "edit" }
3434 func (t *EditTool) Description() string {
35- return "Edit a file via exact-match replacements. Each oldText must be unique in the file. NEVER pass text from a different file as oldText. Prefer small unique blocks; for large or highly divergent rewrites, use write instead."
35+ return "Apply exact substitutions to an existing file. Inspect that file before editing and take every oldText directly from its current contents. Use short excerpts that identify one location; choose write when replacing most or all of a file."
3636 }
3737 func (t *EditTool) Schema() json.RawMessage { return json.RawMessage(editSchema) }
3838
@@ -68,43 +68,32 @@ func (t *EditTool) Execute(ctx context.Context, raw json.RawMessage, progress fu
6868 body := string(bytes.ReplaceAll(orig, []byte("\r\n"), []byte("\n")))
6969
7070 // Validate all edits first (against original content, not sequentially).
71- // oldText == newText is a no-op (destination already has the desired
72- // text) — skip those rather than erroring so equalize/port flows can
73- // continue when a block is already in sync.
74- type span struct {
75- start, end int
76- replacement string
77- }
78- spans := make([]span, 0, len(a.Edits))
79- applied := 0
8071 for i, e := range a.Edits {
8172 if e.OldText == "" {
8273 return core.ToolResult{}, fmt.Errorf("edit %d: oldText must not be empty", i+1)
8374 }
75+ if e.OldText == e.NewText {
76+ return core.ToolResult{}, fmt.Errorf("edit %d: oldText equals newText", i+1)
77+ }
8478 count := strings.Count(body, e.OldText)
8579 if count == 0 {
86- return core.ToolResult{}, fmt.Errorf("edit %d: oldText not found in %s", i+1, a.Path)
80+ return core.ToolResult{}, fmt.Errorf("edit %d: oldText not found in %s; inspect that file again and use a verbatim excerpt with matching spaces and line breaks", i+1, a.Path)
8781 }
8882 if count > 1 {
8983 return core.ToolResult{}, fmt.Errorf("edit %d: oldText matches %d times (must be unique) in %s", i+1, count, a.Path)
9084 }
91- if e.OldText == e.NewText {
92- continue
93- }
94- idx := strings.Index(body, e.OldText)
95- spans = append(spans, span{start: idx, end: idx + len(e.OldText), replacement: e.NewText})
96- applied++
97- }
98-
99- if applied == 0 {
100- // Every edit was already a no-op; file is unchanged.
101- return core.ToolResult{
102- Content: []provider.Content{provider.TextBlock{Text: "already up to date\n"}},
103- Details: map[string]any{"path": path, "edits": 0, "diff": ""},
104- }, nil
10585 }
10686
10787 // Apply atomically (sorted by position so offsets stay valid as we splice).
88+ type span struct {
89+ start, end int
90+ replacement string
91+ }
92+ spans := make([]span, 0, len(a.Edits))
93+ for _, e := range a.Edits {
94+ idx := strings.Index(body, e.OldText)
95+ spans = append(spans, span{start: idx, end: idx + len(e.OldText), replacement: e.NewText})
96+ }
10897 // Check for overlaps.
10998 for i := 0; i < len(spans); i++ {
11099 for j := i + 1; j < len(spans); j++ {
@@ -148,7 +137,7 @@ func (t *EditTool) Execute(ctx context.Context, raw json.RawMessage, progress fu
148137 // want it.
149138 return core.ToolResult{
150139 Content: []provider.Content{provider.TextBlock{Text: diff}},
151- Details: map[string]any{"path": path, "edits": applied, "diff": diff},
140+ Details: map[string]any{"path": path, "edits": len(a.Edits), "diff": diff},
152141 }, nil
153142 }
154143
@@ -28,11 +28,11 @@ type editArgs struct {
28 Edits []editOp `json:"edits"`28 Edits []editOp `json:"edits"`
29 }29 }
30 30
31-const editSchema = `{"type":"object","properties":{"path":{"type":"string"},"edits":{"type":"array","items":{"type":"object","properties":{"oldText":{"type":"string"},"newText":{"type":"string"}},"required":["oldText","newText"]}}},"required":["path","edits"]}`31+const editSchema = `{"type":"object","properties":{"path":{"type":"string","description":"File to modify, given as an absolute path or one relative to the working directory."},"edits":{"type":"array","description":"A set of non-overlapping substitutions, all located in the file content as it existed before this call.","items":{"type":"object","properties":{"oldText":{"type":"string","description":"A verbatim excerpt from the file being modified. Include enough surrounding text to identify exactly one location, without intersecting another edit."},"newText":{"type":"string","description":"Content that will replace the matched excerpt."}},"required":["oldText","newText"]}}},"required":["path","edits"]}`
32 32
33 func (t *EditTool) Name() string { return "edit" }33 func (t *EditTool) Name() string { return "edit" }
34 func (t *EditTool) Description() string {34 func (t *EditTool) Description() string {
35- return "Edit a file via exact-match replacements. Each oldText must be unique in the file. NEVER pass text from a different file as oldText. Prefer small unique blocks; for large or highly divergent rewrites, use write instead."35+ return "Apply exact substitutions to an existing file. Inspect that file before editing and take every oldText directly from its current contents. Use short excerpts that identify one location; choose write when replacing most or all of a file."
36 }36 }
37 func (t *EditTool) Schema() json.RawMessage { return json.RawMessage(editSchema) }37 func (t *EditTool) Schema() json.RawMessage { return json.RawMessage(editSchema) }
38 38
@@ -68,43 +68,32 @@ func (t *EditTool) Execute(ctx context.Context, raw json.RawMessage, progress fu
68 body := string(bytes.ReplaceAll(orig, []byte("\r\n"), []byte("\n")))68 body := string(bytes.ReplaceAll(orig, []byte("\r\n"), []byte("\n")))
69 69
70 // Validate all edits first (against original content, not sequentially).70 // Validate all edits first (against original content, not sequentially).
71- // oldText == newText is a no-op (destination already has the desired
72- // text) — skip those rather than erroring so equalize/port flows can
73- // continue when a block is already in sync.
74- type span struct {
75- start, end int
76- replacement string
77- }
78- spans := make([]span, 0, len(a.Edits))
79- applied := 0
80 for i, e := range a.Edits {71 for i, e := range a.Edits {
81 if e.OldText == "" {72 if e.OldText == "" {
82 return core.ToolResult{}, fmt.Errorf("edit %d: oldText must not be empty", i+1)73 return core.ToolResult{}, fmt.Errorf("edit %d: oldText must not be empty", i+1)
83 }74 }
75+ if e.OldText == e.NewText {
76+ return core.ToolResult{}, fmt.Errorf("edit %d: oldText equals newText", i+1)
77+ }
84 count := strings.Count(body, e.OldText)78 count := strings.Count(body, e.OldText)
85 if count == 0 {79 if count == 0 {
86- return core.ToolResult{}, fmt.Errorf("edit %d: oldText not found in %s", i+1, a.Path)80+ return core.ToolResult{}, fmt.Errorf("edit %d: oldText not found in %s; inspect that file again and use a verbatim excerpt with matching spaces and line breaks", i+1, a.Path)
87 }81 }
88 if count > 1 {82 if count > 1 {
89 return core.ToolResult{}, fmt.Errorf("edit %d: oldText matches %d times (must be unique) in %s", i+1, count, a.Path)83 return core.ToolResult{}, fmt.Errorf("edit %d: oldText matches %d times (must be unique) in %s", i+1, count, a.Path)
90 }84 }
91- if e.OldText == e.NewText {
92- continue
93- }
94- idx := strings.Index(body, e.OldText)
95- spans = append(spans, span{start: idx, end: idx + len(e.OldText), replacement: e.NewText})
96- applied++
97- }
98-
99- if applied == 0 {
100- // Every edit was already a no-op; file is unchanged.
101- return core.ToolResult{
102- Content: []provider.Content{provider.TextBlock{Text: "already up to date\n"}},
103- Details: map[string]any{"path": path, "edits": 0, "diff": ""},
104- }, nil
105 }85 }
106 86
107 // Apply atomically (sorted by position so offsets stay valid as we splice).87 // Apply atomically (sorted by position so offsets stay valid as we splice).
88+ type span struct {
89+ start, end int
90+ replacement string
91+ }
92+ spans := make([]span, 0, len(a.Edits))
93+ for _, e := range a.Edits {
94+ idx := strings.Index(body, e.OldText)
95+ spans = append(spans, span{start: idx, end: idx + len(e.OldText), replacement: e.NewText})
96+ }
108 // Check for overlaps.97 // Check for overlaps.
109 for i := 0; i < len(spans); i++ {98 for i := 0; i < len(spans); i++ {
110 for j := i + 1; j < len(spans); j++ {99 for j := i + 1; j < len(spans); j++ {
@@ -148,7 +137,7 @@ func (t *EditTool) Execute(ctx context.Context, raw json.RawMessage, progress fu
148 // want it.137 // want it.
149 return core.ToolResult{138 return core.ToolResult{
150 Content: []provider.Content{provider.TextBlock{Text: diff}},139 Content: []provider.Content{provider.TextBlock{Text: diff}},
151- Details: map[string]any{"path": path, "edits": applied, "diff": diff},140+ Details: map[string]any{"path": path, "edits": len(a.Edits), "diff": diff},
152 }, nil141 }, nil
153 }142 }
154 143
M packages/agent/tools/tools_test.go +23 -76
@@ -158,98 +158,45 @@ func TestEditAmbiguous(t *testing.T) {
158158 }
159159 }
160160
161-// TestEditNoOpWhenAlreadyInSync: oldText == newText means the destination
162-// already has the desired block. That is success, not an error — equalize
163-// flows must keep going.
164-func TestEditNoOpWhenAlreadyInSync(t *testing.T) {
165- dir := t.TempDir()
166- p := filepath.Join(dir, "a.txt")
167- orig := "hello world\n"
168- if err := os.WriteFile(p, []byte(orig), 0o644); err != nil {
169- t.Fatal(err)
170- }
171- tool := &EditTool{CWD: dir}
172- res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{
173- "path": "a.txt",
174- "edits": []map[string]any{{"oldText": "hello world", "newText": "hello world"}},
175- }), nil)
176- if err != nil {
177- t.Fatalf("no-op edit should succeed: %v", err)
178- }
179- b, _ := os.ReadFile(p)
180- if string(b) != orig {
181- t.Fatalf("file changed on no-op: got %q", b)
182- }
183- got := res.Content[0].(provider.TextBlock).Text
184- if !strings.Contains(got, "already up to date") {
185- t.Fatalf("want already-up-to-date result, got %q", got)
161+func TestEditGuidance(t *testing.T) {
162+ desc := (&EditTool{}).Description()
163+ for _, want := range []string{"Inspect that file", "directly from its current contents", "short excerpts", "write"} {
164+ if !strings.Contains(desc, want) {
165+ t.Errorf("description missing %q: %q", want, desc)
166+ }
186167 }
187-}
188168
189-func TestEditSkipsNoOpAmongRealEdits(t *testing.T) {
190- dir := t.TempDir()
191- p := filepath.Join(dir, "a.txt")
192- if err := os.WriteFile(p, []byte("a\nb\nc\n"), 0o644); err != nil {
169+ var schema map[string]any
170+ if err := json.Unmarshal((&EditTool{}).Schema(), &schema); err != nil {
193171 t.Fatal(err)
194172 }
195- tool := &EditTool{CWD: dir}
196- _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{
197- "path": "a.txt",
198- "edits": []map[string]any{
199- {"oldText": "a", "newText": "a"}, // already in sync
200- {"oldText": "c", "newText": "C"},
201- },
202- }), nil)
203- if err != nil {
204- t.Fatal(err)
205- }
206- b, _ := os.ReadFile(p)
207- if string(b) != "a\nb\nC\n" {
208- t.Fatalf("got %q", b)
173+ properties := schema["properties"].(map[string]any)
174+ edits := properties["edits"].(map[string]any)
175+ items := edits["items"].(map[string]any)
176+ editProperties := items["properties"].(map[string]any)
177+ oldText := editProperties["oldText"].(map[string]any)
178+ if got, _ := oldText["description"].(string); !strings.Contains(got, "file being modified") {
179+ t.Fatalf("oldText schema description missing target-file guidance: %q", got)
209180 }
210181 }
211182
212-// TestEditOldTextNotFoundGuidesRecovery covers the equalize-divergent-files
213-// failure mode: oldText taken from a different file than path. The error
214-// must tell the model to re-read the destination and edit in blocks (or write).
215-func TestEditOldTextNotFoundGuidesRecovery(t *testing.T) {
183+func TestEditNotFoundGuidesRecovery(t *testing.T) {
216184 dir := t.TempDir()
217- p := filepath.Join(dir, "desktop.ts")
218- if err := os.WriteFile(p, []byte("export const timer: ReturnType<typeof setTimeout> | null = null\n"), 0o644); err != nil {
185+ p := filepath.Join(dir, "destination.txt")
186+ if err := os.WriteFile(p, []byte("destination content\n"), 0o644); err != nil {
219187 t.Fatal(err)
220188 }
221189 tool := &EditTool{CWD: dir}
222- // Content shaped like a sibling "browser" file — not present in desktop.ts.
223190 _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{
224- "path": "desktop.ts",
225- "edits": []map[string]any{{
226- "oldText": "export const timer: number | null = null;",
227- "newText": "export const timer: ReturnType<typeof setTimeout> | null = null",
228- }},
191+ "path": "destination.txt",
192+ "edits": []map[string]any{{"oldText": "content from another file", "newText": "replacement"}},
229193 }), nil)
230194 if err == nil {
231195 t.Fatal("want oldText not found error")
232196 }
233- msg := err.Error()
234- for _, want := range []string{
235- "oldText not found",
236- "desktop.ts",
237- } {
238- if !strings.Contains(msg, want) {
239- t.Errorf("error missing %q; got %q", want, msg)
240- }
241- }
242-}
243-
244-func TestEditDescriptionGuidesReadBeforeEdit(t *testing.T) {
245- desc := (&EditTool{}).Description()
246- for _, want := range []string{
247- "different file",
248- "write",
249- "small unique blocks",
250- } {
251- if !strings.Contains(desc, want) {
252- t.Errorf("Description missing %q; got %q", want, desc)
197+ for _, want := range []string{"oldText not found", "destination.txt", "inspect that file again", "matching spaces and line breaks"} {
198+ if !strings.Contains(err.Error(), want) {
199+ t.Errorf("error missing %q: %q", want, err)
253200 }
254201 }
255202 }
@@ -158,98 +158,45 @@ func TestEditAmbiguous(t *testing.T) {
158 }158 }
159 }159 }
160 160
161-// TestEditNoOpWhenAlreadyInSync: oldText == newText means the destination161+func TestEditGuidance(t *testing.T) {
162-// already has the desired block. That is success, not an error — equalize162+ desc := (&EditTool{}).Description()
163-// flows must keep going.163+ for _, want := range []string{"Inspect that file", "directly from its current contents", "short excerpts", "write"} {
164-func TestEditNoOpWhenAlreadyInSync(t *testing.T) {164+ if !strings.Contains(desc, want) {
165- dir := t.TempDir()165+ t.Errorf("description missing %q: %q", want, desc)
166- p := filepath.Join(dir, "a.txt")166+ }
167- orig := "hello world\n"
168- if err := os.WriteFile(p, []byte(orig), 0o644); err != nil {
169- t.Fatal(err)
170- }
171- tool := &EditTool{CWD: dir}
172- res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{
173- "path": "a.txt",
174- "edits": []map[string]any{{"oldText": "hello world", "newText": "hello world"}},
175- }), nil)
176- if err != nil {
177- t.Fatalf("no-op edit should succeed: %v", err)
178- }
179- b, _ := os.ReadFile(p)
180- if string(b) != orig {
181- t.Fatalf("file changed on no-op: got %q", b)
182- }
183- got := res.Content[0].(provider.TextBlock).Text
184- if !strings.Contains(got, "already up to date") {
185- t.Fatalf("want already-up-to-date result, got %q", got)
186 }167 }
187-}
188 168
189-func TestEditSkipsNoOpAmongRealEdits(t *testing.T) {169+ var schema map[string]any
190- dir := t.TempDir()170+ if err := json.Unmarshal((&EditTool{}).Schema(), &schema); err != nil {
191- p := filepath.Join(dir, "a.txt")
192- if err := os.WriteFile(p, []byte("a\nb\nc\n"), 0o644); err != nil {
193 t.Fatal(err)171 t.Fatal(err)
194 }172 }
195- tool := &EditTool{CWD: dir}173+ properties := schema["properties"].(map[string]any)
196- _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{174+ edits := properties["edits"].(map[string]any)
197- "path": "a.txt",175+ items := edits["items"].(map[string]any)
198- "edits": []map[string]any{176+ editProperties := items["properties"].(map[string]any)
199- {"oldText": "a", "newText": "a"}, // already in sync177+ oldText := editProperties["oldText"].(map[string]any)
200- {"oldText": "c", "newText": "C"},178+ if got, _ := oldText["description"].(string); !strings.Contains(got, "file being modified") {
201- },179+ t.Fatalf("oldText schema description missing target-file guidance: %q", got)
202- }), nil)
203- if err != nil {
204- t.Fatal(err)
205- }
206- b, _ := os.ReadFile(p)
207- if string(b) != "a\nb\nC\n" {
208- t.Fatalf("got %q", b)
209 }180 }
210 }181 }
211 182
212-// TestEditOldTextNotFoundGuidesRecovery covers the equalize-divergent-files183+func TestEditNotFoundGuidesRecovery(t *testing.T) {
213-// failure mode: oldText taken from a different file than path. The error
214-// must tell the model to re-read the destination and edit in blocks (or write).
215-func TestEditOldTextNotFoundGuidesRecovery(t *testing.T) {
216 dir := t.TempDir()184 dir := t.TempDir()
217- p := filepath.Join(dir, "desktop.ts")185+ p := filepath.Join(dir, "destination.txt")
218- if err := os.WriteFile(p, []byte("export const timer: ReturnType<typeof setTimeout> | null = null\n"), 0o644); err != nil {186+ if err := os.WriteFile(p, []byte("destination content\n"), 0o644); err != nil {
219 t.Fatal(err)187 t.Fatal(err)
220 }188 }
221 tool := &EditTool{CWD: dir}189 tool := &EditTool{CWD: dir}
222- // Content shaped like a sibling "browser" file — not present in desktop.ts.
223 _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{190 _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{
224- "path": "desktop.ts",191+ "path": "destination.txt",
225- "edits": []map[string]any{{192+ "edits": []map[string]any{{"oldText": "content from another file", "newText": "replacement"}},
226- "oldText": "export const timer: number | null = null;",
227- "newText": "export const timer: ReturnType<typeof setTimeout> | null = null",
228- }},
229 }), nil)193 }), nil)
230 if err == nil {194 if err == nil {
231 t.Fatal("want oldText not found error")195 t.Fatal("want oldText not found error")
232 }196 }
233- msg := err.Error()197+ for _, want := range []string{"oldText not found", "destination.txt", "inspect that file again", "matching spaces and line breaks"} {
234- for _, want := range []string{198+ if !strings.Contains(err.Error(), want) {
235- "oldText not found",199+ t.Errorf("error missing %q: %q", want, err)
236- "desktop.ts",
237- } {
238- if !strings.Contains(msg, want) {
239- t.Errorf("error missing %q; got %q", want, msg)
240- }
241- }
242-}
243-
244-func TestEditDescriptionGuidesReadBeforeEdit(t *testing.T) {
245- desc := (&EditTool{}).Description()
246- for _, want := range []string{
247- "different file",
248- "write",
249- "small unique blocks",
250- } {
251- if !strings.Contains(desc, want) {
252- t.Errorf("Description missing %q; got %q", want, desc)
253 }200 }
254 }201 }
255 }202 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close