Skip to content

Commit 2654b6d

Browse files
vitogsnaiper
authored andcommitted
add environment_file_edit tool (dagger#245)
* add environment_file_edit tool * uses basic search/replace technique * if the search term is ambiguous, each match is printed, with a deterministic hash ID, so the agent can try again with the exact ID * uses Directory.withPatch to efficiently apply edits without rewriting entire files * uses simple in-memory `strings.Replace` - it should be fine for the files that are being edited; if we can't fit a file in memory you've got bigger problems Signed-off-by: Alex Suraci <suraci.alex@gmail.com> * rename variable Signed-off-by: Alex Suraci <suraci.alex@gmail.com> * include line numbers in match disambiguation Signed-off-by: Alex Suraci <suraci.alex@gmail.com> --------- Signed-off-by: Alex Suraci <suraci.alex@gmail.com>
1 parent f077d8a commit 2654b6d

File tree

4 files changed

+207
-8
lines changed

4 files changed

+207
-8
lines changed

environment/filesystem.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@ package environment
22

33
import (
44
"context"
5+
"crypto/sha256"
56
"fmt"
67
"strings"
8+
9+
godiffpatch "github.com/sourcegraph/go-diff-patch"
710
)
811

912
func (env *Environment) FileRead(ctx context.Context, targetFile string, shouldReadEntireFile bool, startLineOneIndexedInclusive int, endLineOneIndexedInclusive int) (string, error) {
@@ -45,6 +48,81 @@ func (env *Environment) FileWrite(ctx context.Context, explanation, targetFile,
4548
return nil
4649
}
4750

51+
func (env *Environment) FileEdit(ctx context.Context, explanation, targetFile, search, replace, matchID string) error {
52+
contents, err := env.container().File(targetFile).Contents(ctx)
53+
if err != nil {
54+
return err
55+
}
56+
57+
// Find all matches of the search text
58+
matches := []int{}
59+
cursor := 0
60+
for {
61+
index := strings.Index(contents[cursor:], search)
62+
if index == -1 {
63+
break
64+
}
65+
actualIndex := cursor + index
66+
matches = append(matches, actualIndex)
67+
cursor = actualIndex + 1
68+
}
69+
70+
if len(matches) == 0 {
71+
return fmt.Errorf("search text not found in file %s", targetFile)
72+
}
73+
74+
// If there are multiple matches and no matchID is provided, return an error with all matches
75+
if len(matches) > 1 && matchID == "" {
76+
var matchDescriptions []string
77+
for i, matchIndex := range matches {
78+
// Generate a unique ID for each match
79+
id := generateMatchID(targetFile, search, replace, i)
80+
81+
// Get context around the match (3 lines before and after)
82+
context := getMatchContext(contents, matchIndex)
83+
84+
matchDescriptions = append(matchDescriptions, fmt.Sprintf("Match %d (ID: %s):\n%s", i+1, id, context))
85+
}
86+
87+
return fmt.Errorf("multiple matches found for search text in %s. Please specify which_match parameter with one of the following IDs:\n\n%s",
88+
targetFile, strings.Join(matchDescriptions, "\n\n"))
89+
}
90+
91+
// Determine which match to replace
92+
var targetMatchIndex int
93+
if len(matches) == 1 {
94+
targetMatchIndex = matches[0]
95+
} else {
96+
// Find the match with the specified ID
97+
found := false
98+
for i, matchIndex := range matches {
99+
id := generateMatchID(targetFile, search, replace, i)
100+
if id == matchID {
101+
targetMatchIndex = matchIndex
102+
found = true
103+
break
104+
}
105+
}
106+
if !found {
107+
return fmt.Errorf("match ID %s not found", matchID)
108+
}
109+
}
110+
111+
// Replace the specific match
112+
newContents := contents[:targetMatchIndex] + replace + contents[targetMatchIndex+len(search):]
113+
114+
// Apply the changes using `Directory.withPatch` so we don't have to spit out
115+
// the entire contents
116+
patch := godiffpatch.GeneratePatch(targetFile, contents, newContents)
117+
ctr := env.container()
118+
err = env.apply(ctx, ctr.WithDirectory(".", ctr.Directory(".").WithPatch(patch)))
119+
if err != nil {
120+
return fmt.Errorf("failed applying file edit, skipping git propagation: %w", err)
121+
}
122+
env.Notes.Add("Edit %s", targetFile)
123+
return nil
124+
}
125+
48126
func (env *Environment) FileDelete(ctx context.Context, explanation, targetFile string) error {
49127
err := env.apply(ctx, env.container().WithoutFile(targetFile))
50128
if err != nil {
@@ -65,3 +143,43 @@ func (env *Environment) FileList(ctx context.Context, path string) (string, erro
65143
}
66144
return out.String(), nil
67145
}
146+
147+
// generateMatchID creates a unique ID for a match based on file, search, replace, and index
148+
func generateMatchID(targetFile, search, replace string, index int) string {
149+
data := fmt.Sprintf("%s:%s:%s:%d", targetFile, search, replace, index)
150+
hash := sha256.Sum256([]byte(data))
151+
return fmt.Sprintf("%x", hash)[:8] // Use first 8 characters of hash
152+
}
153+
154+
// getMatchContext returns the context around a match (3 lines before and after)
155+
func getMatchContext(contents string, matchIndex int) string {
156+
lines := strings.Split(contents, "\n")
157+
158+
// Find which line contains the match
159+
currentPos := 0
160+
matchLine := 0
161+
for i, line := range lines {
162+
if currentPos+len(line) >= matchIndex {
163+
matchLine = i
164+
break
165+
}
166+
currentPos += len(line) + 1 // +1 for newline
167+
}
168+
169+
// Get context lines (3 before, match line, 3 after)
170+
start := max(0, matchLine-3)
171+
end := min(len(lines), matchLine+4)
172+
173+
contextLines := make([]string, 0, end-start)
174+
for i := start; i < end; i++ {
175+
prefix := " "
176+
if i == matchLine {
177+
prefix = "> " // Mark the line containing the match
178+
}
179+
// Include line numbers, which may help the model determine the right match
180+
prefix += fmt.Sprintf("%4d | ", i+1)
181+
contextLines = append(contextLines, fmt.Sprintf("%s%s", prefix, lines[i]))
182+
}
183+
184+
return strings.Join(contextLines, "\n")
185+
}

go.mod

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,17 @@ go 1.24.3
55
toolchain go1.24.4
66

77
require (
8-
dagger.io/dagger v0.18.12
8+
dagger.io/dagger v0.18.14
99
github.com/charmbracelet/bubbletea v1.3.5
1010
github.com/charmbracelet/fang v0.3.0
11+
github.com/charmbracelet/huh v0.7.0
1112
github.com/charmbracelet/lipgloss v1.1.0
1213
github.com/dustin/go-humanize v1.0.1
1314
github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0
1415
github.com/mark3labs/mcp-go v0.29.0
1516
github.com/mitchellh/go-homedir v1.1.0
1617
github.com/pelletier/go-toml/v2 v2.2.4
18+
github.com/sourcegraph/go-diff-patch v0.0.0-20240223163233-798fd1e94a8e
1719
github.com/spf13/cobra v1.9.1
1820
github.com/stretchr/testify v1.10.0
1921
github.com/tiborvass/go-watch v0.0.0-20250607214558-08999a83bf8b
@@ -30,7 +32,6 @@ require (
3032
github.com/cenkalti/backoff/v5 v5.0.2 // indirect
3133
github.com/charmbracelet/bubbles v0.21.0 // indirect
3234
github.com/charmbracelet/colorprofile v0.3.1 // indirect
33-
github.com/charmbracelet/huh v0.7.0 // indirect
3435
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.2 // indirect
3536
github.com/charmbracelet/x/ansi v0.8.0 // indirect
3637
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect

go.sum

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
dagger.io/dagger v0.18.11 h1:6lSfemlbGM2HmdOjhgevrX2+orMDGKU/xTaBMZ+otyY=
2-
dagger.io/dagger v0.18.11/go.mod h1:azlZ24m2br95t0jQHUBpL5SiafeqtVDLl1Itlq6GO+4=
3-
dagger.io/dagger v0.18.12 h1:s7v8aHlzDUogZ/jW92lHC+gljCNRML+0mosfh13R4vs=
4-
dagger.io/dagger v0.18.12/go.mod h1:azlZ24m2br95t0jQHUBpL5SiafeqtVDLl1Itlq6GO+4=
1+
dagger.io/dagger v0.18.14 h1:7+VFqNJffm6Qa8ckNRMfsM64sI5dXbRnZswCQ1jnDF0=
2+
dagger.io/dagger v0.18.14/go.mod h1:azlZ24m2br95t0jQHUBpL5SiafeqtVDLl1Itlq6GO+4=
53
github.com/99designs/gqlgen v0.17.75 h1:GwHJsptXWLHeY7JO8b7YueUI4w9Pom6wJTICosDtQuI=
64
github.com/99designs/gqlgen v0.17.75/go.mod h1:p7gbTpdnHyl70hmSpM8XG8GiKwmCv+T5zkdY8U8bLog=
75
github.com/Khan/genqlient v0.8.1 h1:wtOCc8N9rNynRLXN3k3CnfzheCUNKBcvXmVv5zt6WCs=
86
github.com/Khan/genqlient v0.8.1/go.mod h1:R2G6DzjBvCbhjsEajfRjbWdVglSH/73kSivC9TLWVjU=
7+
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
8+
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
99
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
1010
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
1111
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
@@ -38,15 +38,25 @@ github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2ll
3838
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
3939
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
4040
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
41+
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
42+
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
43+
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
44+
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
4145
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0=
4246
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
43-
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30=
44-
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
47+
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
48+
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
4549
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
4650
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
4751
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
4852
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
53+
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
54+
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
55+
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
56+
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
4957
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
58+
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
59+
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
5060
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
5161
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
5262
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -124,6 +134,8 @@ github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN
124134
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
125135
github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4=
126136
github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
137+
github.com/sourcegraph/go-diff-patch v0.0.0-20240223163233-798fd1e94a8e h1:H+jDTUeF+SVd4ApwnSFoew8ZwGNRfgb9EsZc7LcocAg=
138+
github.com/sourcegraph/go-diff-patch v0.0.0-20240223163233-798fd1e94a8e/go.mod h1:VsUklG6OQo7Ctunu0gS3AtEOCEc2kMB6r5rKzxAes58=
127139
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
128140
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
129141
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=

mcpserver/tools.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ func init() {
137137
EnvironmentFileReadTool,
138138
EnvironmentFileListTool,
139139
EnvironmentFileWriteTool,
140+
EnvironmentFileEditTool,
140141
EnvironmentFileDeleteTool,
141142

142143
EnvironmentAddServiceTool,
@@ -613,6 +614,73 @@ var EnvironmentFileWriteTool = &Tool{
613614
},
614615
}
615616

617+
var EnvironmentFileEditTool = &Tool{
618+
Definition: mcp.NewTool("environment_file_edit",
619+
mcp.WithDescription("Find and replace text in a file."),
620+
mcp.WithString("explanation",
621+
mcp.Description("One sentence explanation for why this file is being edited."),
622+
),
623+
mcp.WithString("environment_source",
624+
mcp.Description("Absolute path to the source git repository for the environment."),
625+
mcp.Required(),
626+
),
627+
mcp.WithString("environment_id",
628+
mcp.Description("The ID of the environment for this command. Must call `environment_create` first."),
629+
mcp.Required(),
630+
),
631+
mcp.WithString("target_file",
632+
mcp.Description("Path of the file to write, absolute or relative to the workdir."),
633+
mcp.Required(),
634+
),
635+
mcp.WithString("search_text",
636+
mcp.Description("The text to find and replace."),
637+
mcp.Required(),
638+
),
639+
mcp.WithString("replace_text",
640+
mcp.Description("The text to insert."),
641+
mcp.Required(),
642+
),
643+
mcp.WithString("which_match",
644+
mcp.Description("The ID of the match to replace, if there were multiple matches."),
645+
),
646+
),
647+
Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
648+
repo, env, err := openEnvironment(ctx, request)
649+
if err != nil {
650+
return mcp.NewToolResultErrorFromErr("unable to open the environment", err), nil
651+
}
652+
653+
targetFile, err := request.RequireString("target_file")
654+
if err != nil {
655+
return nil, err
656+
}
657+
search, err := request.RequireString("search_text")
658+
if err != nil {
659+
return nil, err
660+
}
661+
replace, err := request.RequireString("replace_text")
662+
if err != nil {
663+
return nil, err
664+
}
665+
666+
if err := env.FileEdit(ctx,
667+
request.GetString("explanation", ""),
668+
targetFile,
669+
search,
670+
replace,
671+
request.GetString("which_match", ""),
672+
); err != nil {
673+
return mcp.NewToolResultErrorFromErr("failed to write file", err), nil
674+
}
675+
676+
if err := repo.Update(ctx, env, request.GetString("explanation", "")); err != nil {
677+
return mcp.NewToolResultErrorFromErr("unable to update the environment", err), nil
678+
}
679+
680+
return mcp.NewToolResultText(fmt.Sprintf("file %s edited successfully and committed to container-use/ remote", targetFile)), nil
681+
},
682+
}
683+
616684
var EnvironmentFileDeleteTool = &Tool{
617685
Definition: newEnvironmentTool(
618686
"environment_file_delete",

0 commit comments

Comments
 (0)