feat(rag): grep/glob/line-read + abort recovery + auto-unlock (1.8.1) - #18
Conversation
Device RAG could not search code: only fuzzy hash-vector rag_search with no path:line, no glob, no line-range read, and tools locked behind combo-rag the model often skipped. Empty fuzzy hits looked like "code not found". Adds rag_grep (literal/regex → path:line+context), rag_glob, and startLine/ endLine on rag_read_file. Auto-attaches the pack when an index exists. Empty rag_search now hints to switch to rag_grep. Skips lockfiles; truncated 2500-file walks land in lastError. Also: tool AbortError/timeout results carry recovery hints; Stop no longer renders as a red Error bubble; failed tool chips show status error; empty vision worker replies as an explicit failure, not a soft placeholder. Tests: 642 passing. Play Console walkthrough prompt lives in portfolio _memory/aironcoach-play-console-combo-prompt.md.
PR Reviewer Guide 🔍Here are some key observations to aid the review process:
|
| let matcher: RegExp; | ||
| try { | ||
| matcher = opts.regex | ||
| ? new RegExp(opts.pattern, opts.caseInsensitive ? "gi" : "g") | ||
| : new RegExp( | ||
| opts.pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), | ||
| opts.caseInsensitive ? "gi" : "g", | ||
| ); | ||
| } catch { | ||
| return { matches: [], scannedFiles: 0, truncated: false }; | ||
| } |
There was a problem hiding this comment.
Suggestion: An empty pattern compiles to a zero-length regex and matches every line until maxMatches, so rag_grep({pattern:""}) returns a full page of junk hits marked ok: true. Reject blank patterns up front the same way invalid regexes already fail closed. [possible issue, importance: 6]
| let matcher: RegExp; | |
| try { | |
| matcher = opts.regex | |
| ? new RegExp(opts.pattern, opts.caseInsensitive ? "gi" : "g") | |
| : new RegExp( | |
| opts.pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), | |
| opts.caseInsensitive ? "gi" : "g", | |
| ); | |
| } catch { | |
| return { matches: [], scannedFiles: 0, truncated: false }; | |
| } | |
| if (!opts.pattern) { | |
| return { matches: [], scannedFiles: 0, truncated: false }; | |
| } | |
| let matcher: RegExp; | |
| try { | |
| matcher = opts.regex | |
| ? new RegExp(opts.pattern, opts.caseInsensitive ? "gi" : "g") | |
| : new RegExp( | |
| opts.pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), | |
| opts.caseInsensitive ? "gi" : "g", | |
| ); | |
| } catch { | |
| return { matches: [], scannedFiles: 0, truncated: false }; | |
| } |
| const out = grepChunks(chunks, { | ||
| pattern: String(args.pattern ?? ""), | ||
| regex: args.regex === true, | ||
| caseInsensitive: args.caseInsensitive === true, | ||
| glob: typeof args.glob === "string" ? args.glob : undefined, | ||
| maxMatches: typeof args.maxMatches === "number" ? args.maxMatches : 50, | ||
| context: typeof args.context === "number" ? args.context : 2, | ||
| }); | ||
| result = { | ||
| ok: true, | ||
| ...out, | ||
| ...(out.matches.length === 0 | ||
| ? { | ||
| hint: | ||
| "No matches. Check the pattern (it is case-sensitive by default — try caseInsensitive:true), " + | ||
| "widen the glob, or list candidates with rag_glob.", | ||
| } | ||
| : out.truncated | ||
| ? { | ||
| hint: `Stopped at ${out.matches.length} matches. Narrow with glob:"<pattern>" or a more specific pattern.`, | ||
| } | ||
| : {}), | ||
| }; |
There was a problem hiding this comment.
Suggestion: When pattern is missing/blank, the handler still returns ok: true and the generic “No matches” hint, which steers the model to widen the query instead of fixing the call. Validate required input and fail with ok: false so the model retries with a real pattern. [possible issue, importance: 7]
| const out = grepChunks(chunks, { | |
| pattern: String(args.pattern ?? ""), | |
| regex: args.regex === true, | |
| caseInsensitive: args.caseInsensitive === true, | |
| glob: typeof args.glob === "string" ? args.glob : undefined, | |
| maxMatches: typeof args.maxMatches === "number" ? args.maxMatches : 50, | |
| context: typeof args.context === "number" ? args.context : 2, | |
| }); | |
| result = { | |
| ok: true, | |
| ...out, | |
| ...(out.matches.length === 0 | |
| ? { | |
| hint: | |
| "No matches. Check the pattern (it is case-sensitive by default — try caseInsensitive:true), " + | |
| "widen the glob, or list candidates with rag_glob.", | |
| } | |
| : out.truncated | |
| ? { | |
| hint: `Stopped at ${out.matches.length} matches. Narrow with glob:"<pattern>" or a more specific pattern.`, | |
| } | |
| : {}), | |
| }; | |
| const pattern = String(args.pattern ?? "").trim(); | |
| if (!pattern) { | |
| result = { ok: false, error: "pattern is required" }; | |
| } else { | |
| const out = grepChunks(chunks, { | |
| pattern, | |
| regex: args.regex === true, | |
| caseInsensitive: args.caseInsensitive === true, | |
| glob: typeof args.glob === "string" ? args.glob : undefined, | |
| maxMatches: typeof args.maxMatches === "number" ? args.maxMatches : 50, | |
| context: typeof args.context === "number" ? args.context : 2, | |
| }); | |
| result = { | |
| ok: true, | |
| ...out, | |
| ...(out.matches.length === 0 | |
| ? { | |
| hint: | |
| "No matches. Check the pattern (it is case-sensitive by default — try caseInsensitive:true), " + | |
| "widen the glob, or list candidates with rag_glob.", | |
| } | |
| : out.truncated | |
| ? { | |
| hint: `Stopped at ${out.matches.length} matches. Narrow with glob:"<pattern>" or a more specific pattern.`, | |
| } | |
| : {}), | |
| }; | |
| } |
| if (startLine != null || endLine != null) { | ||
| const lines = file.content.split("\n"); | ||
| const from = (startLine ?? 1) - 1; | ||
| const to = endLine != null ? endLine : lines.length; | ||
| const slice = lines.slice(from, to); | ||
| result = { | ||
| ok: true, | ||
| path: file.path, | ||
| startLine: from + 1, | ||
| endLine: Math.min(to, lines.length), | ||
| totalLines: lines.length, | ||
| content: slice.join("\n"), | ||
| truncated: file.truncated || to < lines.length, | ||
| }; |
There was a problem hiding this comment.
Suggestion: endLine is documented as inclusive, but when startLine > endLine or startLine is past EOF, this still returns ok: true with empty content, which the model can treat as an authoritative empty file range. Clamp/validate the range and return a clear error so the agent re-reads with corrected bounds. [general, importance: 5]
| if (startLine != null || endLine != null) { | |
| const lines = file.content.split("\n"); | |
| const from = (startLine ?? 1) - 1; | |
| const to = endLine != null ? endLine : lines.length; | |
| const slice = lines.slice(from, to); | |
| result = { | |
| ok: true, | |
| path: file.path, | |
| startLine: from + 1, | |
| endLine: Math.min(to, lines.length), | |
| totalLines: lines.length, | |
| content: slice.join("\n"), | |
| truncated: file.truncated || to < lines.length, | |
| }; | |
| if (startLine != null || endLine != null) { | |
| const lines = file.content.split("\n"); | |
| const from = (startLine ?? 1) - 1; | |
| const to = endLine != null ? endLine : lines.length; | |
| if (from >= lines.length || (endLine != null && to < from + 1)) { | |
| result = { | |
| ok: false, | |
| error: `invalid range startLine=${startLine ?? 1} endLine=${endLine ?? lines.length} for ${file.path} (${lines.length} lines)`, | |
| totalLines: lines.length, | |
| }; | |
| } else { | |
| const slice = lines.slice(from, to); | |
| result = { | |
| ok: true, | |
| path: file.path, | |
| startLine: from + 1, | |
| endLine: Math.min(to, lines.length), | |
| totalLines: lines.length, | |
| content: slice.join("\n"), | |
| truncated: file.truncated || to < lines.length, | |
| }; | |
| } |
Review of the 1.8.1 grep surface found two correctness holes: - grepChunks reported chunk-relative lines (wrong for any chunk but the first) and deduped on line text, collapsing genuinely repeated lines. Chunks now record startLine at index time; grep cites real file lines and dedupes overlap by line number. Pre-1.8.1 indexes are flagged lineIsEstimate instead of silently lying. - rag_read_file sliced the chunk-joined snapshot, so line ranges did not match the real file. It now reads live from the granted folder when permission allows (source:"live") and falls back to the snapshot with an explicit note otherwise.
Why
Device RAG was unusable for codebase questions: fuzzy hash-vector
rag_searchonly, nopath:line, no glob, no line-range read, tools locked behindcombo-rag, and empty hits that looked like “code not found.” Separately, Play Console runs showed bareError: The operation was aborted.with no recovery path, Stop looking like a fatal Error, failed tools chips markeddone, and empty vision worker critiques soft-lied as success.What changed
Code search
rag_grep— literal/regex over the index →path:line+ context;glob/maxMatches/caseInsensitiverag_glob— list paths by globrag_read_file—startLine/endLinechunkCount > 0(no skill_read required)rag_searchreturns a hint to userag_greplastErrorError / vision surfacing
Stopped(notError: …)ok:false→ statuserror[VISION WORKER FAILED]messageVerification
packages/core/src/rag/grep.test.tspackages/core/src/agent/errorRecovery.test.tsTest plan
rag_grep({pattern:"buildWorkoutPlan", glob:"**/*.ts"})returns path:lineerrorwith recovery hintPlay Console walkthrough prompt (facts from due diligence): portfolio
_memory/aironcoach-play-console-combo-prompt.md.