Skip to content

Commit 1a680f6

Browse files
committed
better text tools with disambiguation
1 parent 4e6805b commit 1a680f6

8 files changed

Lines changed: 459 additions & 74 deletions

File tree

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
package ioutil
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
)
7+
8+
type MaybeStartLineDiagnostic struct {
9+
RequestedLine int `json:"requestedLine"`
10+
Tolerance int `json:"tolerance"`
11+
ClosestDistance int `json:"closestDistance"`
12+
ClosestStartLines []int `json:"closestStartLines,omitempty"`
13+
Applied bool `json:"applied"`
14+
}
15+
16+
type blockMatchCandidateDiagnostic struct {
17+
StartLine int `json:"startLine"`
18+
EndLine int `json:"endLine"`
19+
BeforeLines []string `json:"beforeLines,omitempty"`
20+
MatchLines []string `json:"matchLines"`
21+
AfterLines []string `json:"afterLines,omitempty"`
22+
}
23+
24+
type blockMatchDiagnostic struct {
25+
CandidateStartLines []int `json:"candidateStartLines,omitempty"`
26+
AdditionalCandidatesOmitted int `json:"additionalCandidatesOmitted,omitempty"`
27+
SampleCandidates []blockMatchCandidateDiagnostic `json:"sampleCandidates,omitempty"`
28+
MaybeStartLine *MaybeStartLineDiagnostic `json:"maybeStartLine,omitempty"`
29+
}
30+
31+
func OneBasedLineNumbers(idxs []int) []int {
32+
if len(idxs) == 0 {
33+
return nil
34+
}
35+
out := make([]int, len(idxs))
36+
for i, idx := range idxs {
37+
out[i] = idx + 1
38+
}
39+
return out
40+
}
41+
42+
// NarrowMatchIndicesByMaybeStartLine applies a soft start-line hint.
43+
//
44+
// Behavior:
45+
// - if maybeStartLine is nil or invalid, no narrowing is applied
46+
// - if there is a unique closest candidate within tolerance, returns only that candidate
47+
// - otherwise returns the original candidates and emits diagnostics explaining the closest set
48+
func NarrowMatchIndicesByMaybeStartLine(
49+
idxs []int,
50+
maybeStartLine *int,
51+
tolerance int,
52+
) ([]int, *MaybeStartLineDiagnostic) {
53+
if len(idxs) == 0 || maybeStartLine == nil || *maybeStartLine <= 0 {
54+
return idxs, nil
55+
}
56+
if tolerance < 0 {
57+
tolerance = 0
58+
}
59+
60+
hint := *maybeStartLine
61+
minDist := -1
62+
closest := make([]int, 0, 1)
63+
64+
for _, idx := range idxs {
65+
dist := idx + 1 - hint
66+
if dist < 0 {
67+
dist = -dist
68+
}
69+
if minDist == -1 || dist < minDist {
70+
minDist = dist
71+
closest = closest[:0]
72+
closest = append(closest, idx)
73+
continue
74+
}
75+
if dist == minDist {
76+
closest = append(closest, idx)
77+
}
78+
}
79+
80+
diag := &MaybeStartLineDiagnostic{
81+
RequestedLine: hint,
82+
Tolerance: tolerance,
83+
ClosestDistance: minDist,
84+
ClosestStartLines: OneBasedLineNumbers(closest),
85+
}
86+
87+
if len(closest) == 1 && minDist <= tolerance {
88+
diag.Applied = true
89+
return []int{closest[0]}, diag
90+
}
91+
92+
return idxs, diag
93+
}
94+
95+
func BuildBlockMatchDiagnosticJSON(
96+
lines []string,
97+
matchIdxs []int,
98+
matchWidth int,
99+
hint *MaybeStartLineDiagnostic,
100+
maxCandidates int,
101+
contextLines int,
102+
) string {
103+
diag := blockMatchDiagnostic{
104+
CandidateStartLines: OneBasedLineNumbers(matchIdxs),
105+
MaybeStartLine: hint,
106+
}
107+
108+
if maxCandidates < 0 {
109+
maxCandidates = 0
110+
}
111+
if contextLines < 0 {
112+
contextLines = 0
113+
}
114+
if matchWidth < 1 {
115+
matchWidth = 1
116+
}
117+
118+
limit := len(matchIdxs)
119+
if maxCandidates > 0 && limit > maxCandidates {
120+
limit = maxCandidates
121+
diag.AdditionalCandidatesOmitted = len(matchIdxs) - maxCandidates
122+
}
123+
124+
if limit > 0 && len(lines) > 0 {
125+
diag.SampleCandidates = make([]blockMatchCandidateDiagnostic, 0, limit)
126+
for _, idx := range matchIdxs[:limit] {
127+
if idx < 0 || idx >= len(lines) {
128+
continue
129+
}
130+
131+
matchEndExclusive := min(idx+matchWidth, len(lines))
132+
133+
beforeStart := max(idx-contextLines, 0)
134+
135+
afterEnd := min(matchEndExclusive+contextLines, len(lines))
136+
137+
diag.SampleCandidates = append(diag.SampleCandidates, blockMatchCandidateDiagnostic{
138+
StartLine: idx + 1,
139+
EndLine: matchEndExclusive,
140+
BeforeLines: cloneStringSlice(lines[beforeStart:idx]),
141+
MatchLines: cloneStringSlice(lines[idx:matchEndExclusive]),
142+
AfterLines: cloneStringSlice(lines[matchEndExclusive:afterEnd]),
143+
})
144+
}
145+
}
146+
147+
b, err := json.Marshal(diag)
148+
if err != nil {
149+
return fmt.Sprintf("candidateStartLines=%v", OneBasedLineNumbers(matchIdxs))
150+
}
151+
return string(b)
152+
}
153+
154+
func cloneStringSlice(in []string) []string {
155+
if len(in) == 0 {
156+
return nil
157+
}
158+
out := make([]string, len(in))
159+
copy(out, in)
160+
return out
161+
}

internal/ioutil/text_utils.go

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,22 @@ func NormalizeLineBlockInput(in []string) []string {
3838
}
3939

4040
func RequireSingleTrimmedBlockMatch(lines, block []string, name string) (int, error) {
41-
return RequireSingleMatch(FindTrimmedBlockMatches(lines, block), name)
41+
idxs := FindTrimmedBlockMatches(lines, block)
42+
if len(idxs) == 0 {
43+
return 0, fmt.Errorf(
44+
"no match found for %s; suggestion=copy a longer distinctive block from the file or read nearby lines first",
45+
name,
46+
)
47+
}
48+
if len(idxs) > 1 {
49+
return 0, fmt.Errorf(
50+
"ambiguous match for %s: found %d occurrences. diagnostics=%s suggestion=copy a longer distinctive block from the file",
51+
name,
52+
len(idxs),
53+
BuildBlockMatchDiagnosticJSON(lines, idxs, len(block), nil, 5, 1),
54+
)
55+
}
56+
return idxs[0], nil
4257
}
4358

4459
func RequireSingleMatch(idxs []int, name string) (int, error) {
@@ -47,9 +62,10 @@ func RequireSingleMatch(idxs []int, name string) (int, error) {
4762
}
4863
if len(idxs) > 1 {
4964
return 0, fmt.Errorf(
50-
"ambiguous match for %s: found %d occurrences; provide a more specific match",
65+
"ambiguous match for %s: found %d occurrences at start lines %v; provide a more specific match",
5166
name,
5267
len(idxs),
68+
OneBasedLineNumbers(idxs),
5369
)
5470
}
5571
return idxs[0], nil
@@ -120,9 +136,9 @@ func EnsureNonOverlappingFixedWidth(matchIdxs []int, width int) error {
120136
for i := 0; i < len(matchIdxs)-1; i++ {
121137
if matchIdxs[i]+width > matchIdxs[i+1] {
122138
return fmt.Errorf(
123-
"overlapping matches detected at line indices %d and %d; provide tighter beforeLines/afterLines to disambiguate",
124-
matchIdxs[i],
125-
matchIdxs[i+1],
139+
"overlapping matches detected at start lines %d and %d; provide tighter surrounding context to disambiguate",
140+
matchIdxs[i]+1,
141+
matchIdxs[i+1]+1,
126142
)
127143
}
128144
}

texttool/deletetextlines.go

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,10 @@ var deleteTextLinesTool = spec.Tool{
1919
Slug: "deletetextlines",
2020
Version: "v1.0.0",
2121
DisplayName: "Delete text lines",
22-
Description: "Delete one or more exact line-block occurrences from a UTF-8 text file. Matching compares TrimSpace(line). Use beforeLines/afterLines as immediate-adjacent context to disambiguate.",
23-
Tags: []string{"text"},
22+
Description: "Delete one or more exact line-block occurrences from a UTF-8 text file. Matching compares TrimSpace(line). " +
23+
"For reliable calls, prefer editing > 2 consecutive lines and use immediate beforeLines/afterLines to reduce ambiguity. " +
24+
"maybeStartLine can softly prefer a nearby occurrence, but the tool still fails unless the final deletion count equals expectedDeletions.",
25+
Tags: []string{"text"},
2426

2527
ArgSchema: spec.JSONSchema(`{
2628
"$schema": "http://json-schema.org/draft-07/schema#",
@@ -34,25 +36,30 @@ var deleteTextLinesTool = spec.Tool{
3436
"type": "array",
3537
"items": { "type": "string" },
3638
"minItems": 1,
37-
"description": "Block of lines to delete. Newline characters inside items are allowed and are treated as line breaks."
39+
"description": "Distinctive block of lines to delete. Prefer editing > 2 consecutive lines. Avoid generic single lines such as blank lines, braces, or repeated return statements. Newline characters inside items are allowed and treated as line breaks."
3840
},
3941
"beforeLines": {
4042
"type": "array",
4143
"items": { "type": "string" },
4244
"minItems": 1,
43-
"description": "Optional block that must appear immediately before matchLines."
45+
"description": "Optional immediate-adjacent lines that must appear directly before matchLines. Use 2-5 neighboring lines to disambiguate."
4446
},
4547
"afterLines": {
4648
"type": "array",
4749
"items": { "type": "string" },
4850
"minItems": 1,
49-
"description": "Optional block that must appear immediately after matchLines."
51+
"description": "Optional immediate-adjacent lines that must appear directly after matchLines. Use 2-5 neighboring lines to disambiguate."
52+
},
53+
"maybeStartLine": {
54+
"type": "integer",
55+
"minimum": 1,
56+
"description": "Optional 1-based approximate start line hint. Best used when you expect one deletion. If several matches exist, the tool will prefer a uniquely closest nearby match within a small built-in tolerance; otherwise it still fails and reports candidates."
5057
},
5158
"expectedDeletions": {
5259
"type": "integer",
5360
"minimum": 1,
5461
"default": 1,
55-
"description": "Fail if the number of matched blocks deleted != this value."
62+
"description": "Fail unless the number of deleted blocks equals this value. Leave at 1 for the common case of a single intended deletion."
5663
}
5764
},
5865
"required": ["path", "matchLines"],
@@ -70,6 +77,7 @@ type DeleteTextLinesArgs struct {
7077
MatchLines []string `json:"matchLines"`
7178
BeforeLines []string `json:"beforeLines,omitempty"`
7279
AfterLines []string `json:"afterLines,omitempty"`
80+
MaybeStartLine *int `json:"maybeStartLine,omitempty"`
7381
ExpectedDeletions int `json:"expectedDeletions,omitempty"` // default 1
7482
}
7583

@@ -100,6 +108,9 @@ func deleteTextLines(
100108
matchLines := ioutil.NormalizeLineBlockInput(args.MatchLines)
101109
beforeLines := ioutil.NormalizeLineBlockInput(args.BeforeLines)
102110
afterLines := ioutil.NormalizeLineBlockInput(args.AfterLines)
111+
if args.MaybeStartLine != nil && *args.MaybeStartLine < 1 {
112+
args.MaybeStartLine = nil
113+
}
103114
expected := args.ExpectedDeletions
104115
if expected <= 0 {
105116
expected = 1
@@ -114,11 +125,32 @@ func deleteTextLines(
114125
if err := ioutil.EnsureNonOverlappingFixedWidth(matchIdxs, len(matchLines)); err != nil {
115126
return nil, err
116127
}
128+
var hintDiag *ioutil.MaybeStartLineDiagnostic
129+
if expected == 1 {
130+
matchIdxs, hintDiag = ioutil.NarrowMatchIndicesByMaybeStartLine(
131+
matchIdxs,
132+
args.MaybeStartLine,
133+
maybeStartLineTolerance,
134+
)
135+
}
117136
if len(matchIdxs) != expected {
137+
suggestion := "copy a longer unique matchLines block from the file and add immediate beforeLines/afterLines"
138+
if expected == 1 {
139+
suggestion += "; if you know roughly where the block is, set maybeStartLine near the intended start line"
140+
}
118141
return nil, fmt.Errorf(
119-
"delete match count mismatch: expected %d, found %d (provide tighter beforeLines/afterLines to disambiguate)",
142+
"delete match count mismatch: expected %d, found %d. diagnostics=%s suggestion=%s",
120143
expected,
121144
len(matchIdxs),
145+
ioutil.BuildBlockMatchDiagnosticJSON(
146+
tf.Lines,
147+
matchIdxs,
148+
len(matchLines),
149+
hintDiag,
150+
maxAmbiguityDiagnosticCandidates,
151+
ambiguityDiagnosticContextLines,
152+
),
153+
suggestion,
122154
)
123155
}
124156

0 commit comments

Comments
 (0)