-
Notifications
You must be signed in to change notification settings - Fork 0
Ground explicit file edits in real file contents #34
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,165 @@ | ||
| package engine | ||
|
|
||
| import ( | ||
| "encoding/json" | ||
| "fmt" | ||
| "os" | ||
| "path/filepath" | ||
| "regexp" | ||
| "slices" | ||
| "strings" | ||
|
|
||
| "github.com/CoreyRDean/intent/internal/model" | ||
| ) | ||
|
|
||
| type toolCallRecord struct { | ||
| Name string | ||
| Arguments json.RawMessage | ||
| } | ||
|
|
||
| var ( | ||
| fileEditVerbPattern = regexp.MustCompile(`(?i)\b(modify|edit|update|rewrite|replace|append|prepend|insert|remove|delete|change)\b`) | ||
| pathTokenPattern = regexp.MustCompile(`(?:^|[\s"'` + "`" + `])((?:~|\.{1,2}|/)[^\s"'` + "`" + `,;:()]+|(?:\.[A-Za-z0-9_-]+)|(?:[A-Za-z0-9._-]+\.[A-Za-z0-9._-]+))`) | ||
| ) | ||
|
|
||
| func needsFileGrounding(prompt string, resp *model.Response, calls []toolCallRecord, cwd string) ([]string, bool) { | ||
| if resp == nil { | ||
| return nil, false | ||
| } | ||
| if resp.Approach != model.ApproachCommand && resp.Approach != model.ApproachScript { | ||
| return nil, false | ||
| } | ||
| targets := explicitEditTargets(prompt) | ||
| if len(targets) == 0 { | ||
| return nil, false | ||
| } | ||
| grounded := groundedTargets(calls, targets, cwd) | ||
| missing := make([]string, 0, len(targets)) | ||
| for _, target := range targets { | ||
| if !grounded[target] { | ||
| missing = append(missing, target) | ||
| } | ||
| } | ||
| return missing, len(missing) > 0 | ||
| } | ||
|
|
||
| func explicitEditTargets(prompt string) []string { | ||
| if !fileEditVerbPattern.MatchString(prompt) { | ||
| return nil | ||
| } | ||
| matches := pathTokenPattern.FindAllStringSubmatch(prompt, -1) | ||
| if len(matches) == 0 { | ||
| return nil | ||
| } | ||
| seen := map[string]struct{}{} | ||
| out := make([]string, 0, len(matches)) | ||
| for _, match := range matches { | ||
| if len(match) < 2 { | ||
| continue | ||
| } | ||
| token := cleanPathToken(match[1]) | ||
| if token == "" || looksLikeNonPathToken(token) { | ||
| continue | ||
| } | ||
| if _, ok := seen[token]; ok { | ||
| continue | ||
| } | ||
| seen[token] = struct{}{} | ||
| out = append(out, token) | ||
| } | ||
| return out | ||
| } | ||
|
|
||
| func groundedTargets(calls []toolCallRecord, targets []string, cwd string) map[string]bool { | ||
| grounded := make(map[string]bool, len(targets)) | ||
| home, _ := os.UserHomeDir() | ||
| for _, call := range calls { | ||
| if call.Name != "read_file" { | ||
| continue | ||
| } | ||
| var args struct { | ||
| Path string `json:"path"` | ||
| } | ||
| if err := json.Unmarshal(call.Arguments, &args); err != nil || strings.TrimSpace(args.Path) == "" { | ||
| continue | ||
| } | ||
| for _, target := range targets { | ||
| if targetMatchesReadPath(target, args.Path, cwd, home) { | ||
|
Comment on lines
+83
to
+87
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
A target is considered grounded solely from Useful? React with 👍 / 👎. |
||
| grounded[target] = true | ||
| } | ||
| } | ||
| } | ||
| return grounded | ||
| } | ||
|
|
||
| func targetMatchesReadPath(target string, readPath string, cwd string, home string) bool { | ||
| target = cleanPathToken(target) | ||
| readPath = cleanPathToken(readPath) | ||
| if target == "" || readPath == "" { | ||
| return false | ||
| } | ||
| normTarget := normalizePathForMatch(target, cwd, home) | ||
| normRead := normalizePathForMatch(readPath, cwd, home) | ||
| if normTarget != "" && normRead != "" && normTarget == normRead { | ||
| return true | ||
| } | ||
| // Bare filenames and dotfiles are ambiguous about cwd vs home, so fall | ||
| // back to basename equality when the user named a single file token. | ||
| if !strings.Contains(target, "/") { | ||
| return filepath.Base(readPath) == target | ||
| } | ||
| return false | ||
| } | ||
|
|
||
| func normalizePathForMatch(p string, cwd string, home string) string { | ||
| switch { | ||
| case p == "": | ||
| return "" | ||
| case strings.HasPrefix(p, "~/"): | ||
| if home == "" { | ||
| return filepath.Clean(p) | ||
| } | ||
| return filepath.Clean(filepath.Join(home, strings.TrimPrefix(p, "~/"))) | ||
| case filepath.IsAbs(p): | ||
| return filepath.Clean(p) | ||
| case strings.HasPrefix(p, "./"), strings.HasPrefix(p, "../"): | ||
| if cwd == "" { | ||
| return filepath.Clean(p) | ||
| } | ||
| return filepath.Clean(filepath.Join(cwd, p)) | ||
| default: | ||
| return filepath.Clean(p) | ||
| } | ||
| } | ||
|
|
||
| func buildFileGroundingRepairMessage(targets []string) string { | ||
| list := strings.Join(targets, ", ") | ||
| return fmt.Sprintf( | ||
| "You returned a command or script for a file-edit request without first reading the target file(s): %s. This is invalid. Your next response MUST be approach=tool_call with read_file on each missing target (resolve ~ or env vars first if needed), or approach=clarify/refuse if the file cannot be read. Do not emit pseudocode or a non-mutating snippet.", | ||
| list, | ||
| ) | ||
| } | ||
|
|
||
| func buildFileGroundingRefusal(targets []string) *model.Response { | ||
| list := strings.Join(targets, ", ") | ||
| return &model.Response{ | ||
| IntentSummary: "Refused: file edit was not grounded in the target file contents.", | ||
| Approach: model.ApproachRefuse, | ||
| RefusalReason: fmt.Sprintf("intent could not ground the requested edit in the current contents of %s; rerun with a readable path or narrower request", list), | ||
| } | ||
| } | ||
|
|
||
| func cleanPathToken(token string) string { | ||
| token = strings.TrimSpace(token) | ||
| token = strings.Trim(token, `"'`+"`") | ||
| token = strings.TrimRight(token, ".,;:!?)]}") | ||
| token = strings.TrimLeft(token, "([{") | ||
| return token | ||
| } | ||
|
|
||
| func looksLikeNonPathToken(token string) bool { | ||
| if token == "" || strings.HasPrefix(token, "--") || strings.Contains(token, "://") { | ||
| return true | ||
| } | ||
| return slices.Contains([]string{"safe", "network", "mutates", "destructive", "sudo"}, strings.ToLower(token)) | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The file-target regex treats any dotted token as a path, so prompts like “update Node to 20.11.1” or “update to v1.2” will be classified as explicit file edits and trigger
read_filerepair/refusal even when no file path was requested. BecauseneedsFileGroundingruns on every command/script response, this can block valid non-file workflows that include common version strings.Useful? React with 👍 / 👎.