Skip to content

Commit 80514b0

Browse files
bugerclaude
andauthored
fix: code explorer search prompts — concept dedup and circuit breaker (#539)
* fix: improve code explorer search prompts to prevent wasted retries - Expand no-results guidance: distinguish subfolder vs workspace-root searches, suggest widening scope before giving up - Add fuzzy concept dedup: normalizes queries (strips quotes, "func" prefix, dots, case) to detect that "func ctxGetData", "ctxGetData", "ctx.GetData" are all the same failed concept - Add circuit breaker: after 4 consecutive no-result searches, block further searches and force strategy change (extract, listFiles, or answer with what was found) - Add explicit anti-pattern examples showing the worst retry loop (quote/syntax variations of non-existent functions) with concrete fix options Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: remove func/type prefix stripping from concept normalizer The Rust search engine already handles "func" and "type" as stopwords, so stripping them in JS normalization is redundant and causes false positives: "type Config" and "Config" are genuinely different search intents but would collide after stripping. Keep only syntax-level normalization (quotes, dots, underscores, case) which catches the actual problem pattern without risk. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add concept normalizer tests for cross-language false positive safety 33 tests validating the fuzzy search dedup normalizer: - Syntax variations correctly collapse (quotes, dots, snake/camel/kebab) - Language keywords preserved (func, type, impl, class, def, trait) - Structural chars preserved (::, (), <>, /) - Known acceptable collisions documented (http.Server vs httpServer) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add concept dedup counter integration tests 7 tests proving the exact counting flow: - 2 real failures → 3rd blocked (no search executed) - No double-counting between block path and track path - Counts increment correctly (3→4→5) on blocked attempts - Different concepts and paths tracked independently - Successful search resets circuit breaker counter - Concept dedup fires before circuit breaker when both apply Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f1086e0 commit 80514b0

3 files changed

Lines changed: 462 additions & 3 deletions

File tree

npm/src/tools/vercel.js

Lines changed: 101 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -248,8 +248,23 @@ function buildSearchDelegateTask({ searchQuery, searchPath, exact, language, all
248248
'- Searching "getUserData" ALREADY matches "get", "user", "data" and their variations.',
249249
'- NEVER repeat the same search query — you will get the same results. Changing the path does NOT change this.',
250250
'- NEVER search trivial variations of the same keyword (e.g., AllowedIPs then allowedIps then allowed_ips). This is wasteful — probe handles it.',
251-
'- If a search returns no results, the term likely does not exist. Try a genuinely DIFFERENT keyword or concept, not a variation.',
252-
'- If 2-3 searches return no results for a concept, STOP searching for it and move on. Do NOT keep retrying.',
251+
'',
252+
'When a search returns no results:',
253+
'- If you searched a SUBFOLDER (e.g., path="gateway/"), the term might exist elsewhere.',
254+
' Try searching from the workspace root (omit the path parameter) or a different directory.',
255+
' But do NOT retry the same subfolder with different quoting — that will not help.',
256+
'- If you searched the WORKSPACE ROOT and got no results, the term does not exist in this codebase.',
257+
' Changing quotes, adding "func " prefix, or switching to method syntax will NOT help.',
258+
'- These are ALL the same failed search, NOT different searches:',
259+
' search("func ctxGetData") → no results',
260+
' search("ctxGetData") → no results ← WASTED, same concept, different quoting',
261+
' search(ctxGetData) → no results ← WASTED, same concept, no quotes',
262+
' search("ctx.GetData") → no results ← WASTED, method syntax of same concept',
263+
' After the FIRST "no results" at a given scope, either widen the search path or try',
264+
' a fundamentally different approach: search for a broader concept, use listFiles',
265+
' to discover actual function names, or extract a known file to read real code.',
266+
'- If 2 searches return no results for a concept (across different scopes), the code likely',
267+
' uses different naming than you expect — discover the real names via extract or listFiles.',
253268
'',
254269
'When to use exact=true:',
255270
'- Use exact=true when searching for a KNOWN symbol name (function, type, variable, struct).',
@@ -302,6 +317,21 @@ function buildSearchDelegateTask({ searchQuery, searchPath, exact, language, all
302317
' → search "ForwardMessage" → search "ForwardMessage" → search "ForwardMessage" (WRONG: repeating the exact same query)',
303318
' → search "authentication" → wait → search "session management" → wait (WRONG: these are independent, run them in parallel)',
304319
'',
320+
' WORST pattern — retrying a non-existent function with quote/syntax variations (this wastes 30 minutes):',
321+
' → search "func ctxGetData" → no results',
322+
' → search "ctxGetData" → no results ← WRONG: same term without "func" prefix',
323+
' → search "ctx.GetData" → no results ← WRONG: method syntax of same concept',
324+
' → search "ctx.SetData" → no results ← WRONG: Set variant of same concept',
325+
' → search ctxGetData → no results ← WRONG: unquoted version of same term',
326+
' → extract api.go → extract api.go → extract api.go (8 times!) ← WRONG: re-reading same file',
327+
' FIX: After "func ctxGetData" returns no results in gateway/:',
328+
' Option A: Widen scope — search from the workspace root (omit path) in case the',
329+
' function is defined in a different package (e.g., apidef/, user/, config/).',
330+
' Option B: Discover real names — extract a file you KNOW uses context (e.g., a',
331+
' middleware file) and READ what functions it actually calls.',
332+
' Option C: Browse — use listFiles to see what files exist and extract the relevant ones.',
333+
' NEVER: retry the same concept with different quoting in the same directory.',
334+
'',
305335
'Keyword tips:',
306336
'- Common programming keywords are filtered as stopwords when unquoted: function, class, return, new, struct, impl, var, let, const, etc.',
307337
'- Avoid searching for these alone — combine with a specific term (e.g., "middleware function" is fine, "function" alone is too generic).',
@@ -340,7 +370,7 @@ function buildSearchDelegateTask({ searchQuery, searchPath, exact, language, all
340370
' - Type references and imports → include type definitions.',
341371
' - Registered handlers/middleware → include all registered items.',
342372
'6. If a search returns results, use extract to verify relevance. Run multiple extracts in parallel too.',
343-
'7. If a search returns NO results, the term does not exist. Do NOT retry with variations. Move on.',
373+
'7. If a search returns NO results: widen the path scope if you searched a subfolder, or move on. Do NOT retry with quote/syntax variations — they search the same index.',
344374
'8. Once you have enough targets (typically 5-15), output your final JSON answer immediately.',
345375
'',
346376
`Query: ${searchQuery}`,
@@ -388,8 +418,30 @@ export const searchTool = (options = {}) => {
388418
const dupBlockCounts = new Map();
389419
// Track pagination counts per query to cap runaway pagination
390420
const paginationCounts = new Map();
421+
// Track consecutive no-result searches (circuit breaker)
422+
let consecutiveNoResults = 0;
423+
const MAX_CONSECUTIVE_NO_RESULTS = 4;
424+
// Track normalized query concepts for fuzzy dedup (catches quote/syntax variations)
425+
const failedConcepts = new Map(); // normalizedKey → count
391426
const MAX_PAGES_PER_QUERY = 3;
392427

428+
/**
429+
* Normalize a search query to detect syntax-level duplicates.
430+
* Strips quotes, dots, underscores/hyphens, and lowercases.
431+
* "ctxGetData", "ctx.GetData", "ctx_get_data" all → "ctxgetdata"
432+
* Note: does NOT strip language keywords (func, type) — those change search
433+
* semantics and are already handled as stopwords by the Rust search engine.
434+
*/
435+
function normalizeQueryConcept(query) {
436+
if (!query) return '';
437+
return query
438+
.replace(/^["']|["']$/g, '') // strip outer quotes
439+
.replace(/\./g, '') // "ctx.GetData" → "ctxGetData"
440+
.replace(/[_\-\s]+/g, '') // strip underscores/hyphens/spaces
441+
.toLowerCase()
442+
.trim();
443+
}
444+
393445
return tool({
394446
name: 'search',
395447
description: searchDelegate
@@ -478,6 +530,35 @@ export const searchTool = (options = {}) => {
478530
}
479531
previousSearches.set(searchKey, { hadResults: false });
480532
paginationCounts.set(searchKey, 0);
533+
534+
// Fuzzy concept dedup: catch quote/syntax variations of the same failed concept
535+
// e.g., "func ctxGetData", "ctxGetData", "ctx.GetData" all normalize to "ctxgetdata"
536+
const normalizedKey = `${searchPath}::${normalizeQueryConcept(searchQuery)}`;
537+
if (failedConcepts.has(normalizedKey) && failedConcepts.get(normalizedKey) >= 2) {
538+
const conceptCount = failedConcepts.get(normalizedKey) + 1;
539+
failedConcepts.set(normalizedKey, conceptCount);
540+
if (debug) {
541+
console.error(`[CONCEPT-DEDUP] Blocked variation of failed concept (${conceptCount}x): "${searchQuery}" normalized to "${normalizeQueryConcept(searchQuery)}"`);
542+
}
543+
const isSubfolder = path && path !== effectiveSearchCwd && path !== '.';
544+
const scopeHint = isSubfolder
545+
? `\n- Try searching from the workspace root (omit the path parameter) — the term may exist in a different directory`
546+
: `\n- The term does not exist in this codebase at any path`;
547+
return `CONCEPT ALREADY FAILED (${conceptCount} variations tried). You already searched for "${normalizeQueryConcept(searchQuery)}" with different quoting/syntax in this path and got NO results each time. Changing quotes, adding "func" prefix, or switching to method syntax will NOT change the results.\n\nChange your strategy:${scopeHint}\n- Use extract on a file you ALREADY found to read actual code and discover real function/type names\n- Use listFiles to browse directories and find what functions actually exist\n- Search for a BROADER concept (e.g., instead of "ctxGetData", try "context" or "middleware data access")\n- If you have enough information from prior searches, provide your final answer NOW`;
548+
}
549+
550+
// Circuit breaker: too many consecutive no-result searches means the model
551+
// is stuck in a loop guessing names that don't exist
552+
if (consecutiveNoResults >= MAX_CONSECUTIVE_NO_RESULTS) {
553+
if (debug) {
554+
console.error(`[CIRCUIT-BREAKER] ${consecutiveNoResults} consecutive no-result searches, blocking: "${searchQuery}"`);
555+
}
556+
const isSubfolderCB = path && path !== effectiveSearchCwd && path !== '.';
557+
const cbScopeHint = isSubfolderCB
558+
? `\n- You have been searching in "${path}" — try searching from the workspace root or a different directory`
559+
: '';
560+
return `CIRCUIT BREAKER: Your last ${consecutiveNoResults} searches ALL returned no results. You appear to be guessing function/type names that don't match what's actually in the code.\n\nChange your approach:${cbScopeHint}\n1. Use extract on files you already found — read the actual code to discover real function names\n2. Use listFiles to browse directories and see what files/functions actually exist\n3. If you found some results earlier, those are likely sufficient — provide your final answer\n\nRetrying search query variations will not help. Discover real names from real code instead.`;
561+
}
481562
} else {
482563
// Cap pagination to prevent runaway page-through of broad queries
483564
const pageCount = (paginationCounts.get(searchKey) || 0) + 1;
@@ -493,11 +574,28 @@ export const searchTool = (options = {}) => {
493574
const result = maybeAnnotate(await runRawSearch());
494575
// Track whether this search had results for better dedup messages
495576
if (typeof result === 'string' && result.includes('No results found')) {
577+
// Track consecutive no-results and failed concepts for circuit breaker
578+
consecutiveNoResults++;
579+
const normalizedKey = `${searchPath}::${normalizeQueryConcept(searchQuery)}`;
580+
failedConcepts.set(normalizedKey, (failedConcepts.get(normalizedKey) || 0) + 1);
581+
if (debug) {
582+
console.error(`[NO-RESULTS] consecutiveNoResults=${consecutiveNoResults}, concept "${normalizeQueryConcept(searchQuery)}" failed ${failedConcepts.get(normalizedKey)}x`);
583+
}
496584
// Append contextual hint for ticket/issue ID queries
497585
if (/^[A-Z]+-\d+$/.test(searchQuery.trim()) || /^[A-Z]+-\d+$/.test(searchQuery.replace(/"/g, '').trim())) {
498586
return result + '\n\n⚠️ Your query looks like a ticket/issue ID (e.g., JIRA-1234). Ticket IDs are rarely present in source code. Search for the technical concepts described in the ticket instead (e.g., function names, error messages, variable names).';
499587
}
588+
// Add a hint when approaching the circuit breaker threshold
589+
if (consecutiveNoResults >= MAX_CONSECUTIVE_NO_RESULTS - 1) {
590+
const isSubfolderWarn = path && path !== effectiveSearchCwd && path !== '.';
591+
const warnScopeHint = isSubfolderWarn
592+
? ` You are searching in "${path}" — consider searching from the workspace root or a different directory.`
593+
: '';
594+
return result + `\n\n⚠️ WARNING: ${consecutiveNoResults} consecutive searches returned no results.${warnScopeHint} Before your next action: use extract on a file you already found to read actual code, or use listFiles to discover what functions really exist. One more failed search will trigger the circuit breaker.`;
595+
}
500596
} else if (typeof result === 'string') {
597+
// Successful search — reset consecutive counter
598+
consecutiveNoResults = 0;
501599
const entry = previousSearches.get(searchKey);
502600
if (entry) entry.hadResults = true;
503601
}
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
/**
2+
* Tests for the concept dedup counter and circuit breaker in the search tool.
3+
* Verifies the exact counting sequence: 2 real failures → 3rd blocked,
4+
* and that no double-counting occurs between the block path and track path.
5+
*/
6+
import { describe, test, expect, jest, beforeEach } from '@jest/globals';
7+
import { fileURLToPath } from 'url';
8+
import { dirname, resolve } from 'path';
9+
10+
const __filename = fileURLToPath(import.meta.url);
11+
const __dirname = dirname(__filename);
12+
13+
// Mock the 'ai' package
14+
jest.mock('ai', () => ({
15+
tool: jest.fn((config) => ({
16+
name: config.name,
17+
description: config.description,
18+
inputSchema: config.inputSchema,
19+
execute: config.execute
20+
}))
21+
}));
22+
23+
// Mock search to return controlled results
24+
let mockSearchResult = 'No results found';
25+
const mockSearch = jest.fn(async () => mockSearchResult);
26+
27+
jest.unstable_mockModule(resolve(__dirname, '../../src/search.js'), () => ({
28+
search: mockSearch
29+
}));
30+
jest.unstable_mockModule(resolve(__dirname, '../../src/extract.js'), () => ({
31+
extract: jest.fn()
32+
}));
33+
jest.unstable_mockModule(resolve(__dirname, '../../src/delegate.js'), () => ({
34+
delegate: jest.fn()
35+
}));
36+
jest.unstable_mockModule(resolve(__dirname, '../../src/tools/analyzeAll.js'), () => ({
37+
analyzeAll: jest.fn()
38+
}));
39+
40+
const { searchTool } = await import('../../src/tools/vercel.js');
41+
42+
describe('Concept dedup counter', () => {
43+
let tool;
44+
45+
beforeEach(() => {
46+
mockSearchResult = 'No results found';
47+
mockSearch.mockClear();
48+
// Create a fresh search tool each test (fresh internal counters)
49+
tool = searchTool({ cwd: '/test', debug: false });
50+
});
51+
52+
test('allows first two syntax variations, blocks the third', async () => {
53+
// Search 1: "ctxGetData" — real search runs, no results
54+
const r1 = await tool.execute({ query: 'ctxGetData', path: '/test' });
55+
expect(r1).toContain('No results found');
56+
expect(r1).not.toContain('CONCEPT ALREADY FAILED');
57+
expect(mockSearch).toHaveBeenCalledTimes(1);
58+
59+
// Search 2: "ctx.GetData" — different raw query, same normalized concept — real search runs
60+
const r2 = await tool.execute({ query: 'ctx.GetData', path: '/test' });
61+
expect(r2).toContain('No results found');
62+
expect(r2).not.toContain('CONCEPT ALREADY FAILED');
63+
expect(mockSearch).toHaveBeenCalledTimes(2);
64+
65+
// Search 3: "ctx_get_data" — 3rd attempt — BLOCKED, search NOT called
66+
const r3 = await tool.execute({ query: 'ctx_get_data', path: '/test' });
67+
expect(r3).toContain('CONCEPT ALREADY FAILED');
68+
expect(r3).toContain('3 variations tried');
69+
expect(mockSearch).toHaveBeenCalledTimes(2); // NOT called again
70+
});
71+
72+
test('4th and 5th attempts also blocked with correct counts (no double-counting)', async () => {
73+
await tool.execute({ query: 'ctxGetData', path: '/test' });
74+
await tool.execute({ query: 'ctx.GetData', path: '/test' });
75+
expect(mockSearch).toHaveBeenCalledTimes(2);
76+
77+
// 3rd — blocked, count = 3
78+
const r3 = await tool.execute({ query: 'ctx_get_data', path: '/test' });
79+
expect(r3).toContain('3 variations tried');
80+
81+
// 4th — blocked, count = 4 (kebab-case variant, unique raw query)
82+
const r4 = await tool.execute({ query: 'ctx-get-data', path: '/test' });
83+
expect(r4).toContain('4 variations tried');
84+
85+
// 5th — blocked, count = 5 (UPPER_SNAKE, unique raw query)
86+
const r5 = await tool.execute({ query: 'CTX_GET_DATA', path: '/test' });
87+
expect(r5).toContain('5 variations tried');
88+
89+
// Search was never called after the 2nd — only 2 real searches ran
90+
expect(mockSearch).toHaveBeenCalledTimes(2);
91+
});
92+
93+
test('different concepts are tracked independently', async () => {
94+
// Fail concept A twice
95+
await tool.execute({ query: 'ctxGetData', path: '/test' });
96+
await tool.execute({ query: 'ctx.GetData', path: '/test' });
97+
98+
// Concept B should still be allowed (different normalized key)
99+
const r = await tool.execute({ query: 'handleRequest', path: '/test' });
100+
expect(r).toContain('No results found');
101+
expect(r).not.toContain('CONCEPT ALREADY FAILED');
102+
expect(mockSearch).toHaveBeenCalledTimes(3); // all 3 ran real searches
103+
});
104+
105+
test('same concept in different paths tracked independently', async () => {
106+
// Fail in path /test/src twice
107+
await tool.execute({ query: 'ctxGetData', path: '/test/src' });
108+
await tool.execute({ query: 'ctx.GetData', path: '/test/src' });
109+
110+
// Same concept in /test/lib — different path, should be allowed
111+
const r = await tool.execute({ query: 'ctxGetData', path: '/test/lib' });
112+
expect(r).toContain('No results found');
113+
expect(r).not.toContain('CONCEPT ALREADY FAILED');
114+
});
115+
116+
test('successful search resets consecutive no-results counter', async () => {
117+
// 3 consecutive no-results
118+
await tool.execute({ query: 'missing1', path: '/test' });
119+
await tool.execute({ query: 'missing2', path: '/test' });
120+
await tool.execute({ query: 'missing3', path: '/test' });
121+
122+
// Successful search resets the counter
123+
mockSearchResult = 'Found: function handleRequest() { ... }';
124+
await tool.execute({ query: 'handleRequest', path: '/test' });
125+
126+
// After reset, a new no-result search should NOT trigger circuit breaker
127+
mockSearchResult = 'No results found';
128+
const r = await tool.execute({ query: 'anotherMissing', path: '/test' });
129+
expect(r).toContain('No results found');
130+
expect(r).not.toContain('CIRCUIT BREAKER');
131+
});
132+
133+
test('circuit breaker triggers after 4 consecutive no-result searches', async () => {
134+
// 4 consecutive no-result searches with different concepts
135+
await tool.execute({ query: 'missing1', path: '/test' });
136+
await tool.execute({ query: 'missing2', path: '/test' });
137+
await tool.execute({ query: 'missing3', path: '/test' });
138+
await tool.execute({ query: 'missing4', path: '/test' });
139+
140+
// 5th should be blocked by circuit breaker
141+
const r = await tool.execute({ query: 'missing5', path: '/test' });
142+
expect(r).toContain('CIRCUIT BREAKER');
143+
// Search was NOT executed for the 5th
144+
expect(mockSearch).toHaveBeenCalledTimes(4);
145+
});
146+
147+
test('concept dedup fires before circuit breaker when applicable', async () => {
148+
// Same concept twice
149+
await tool.execute({ query: 'getData', path: '/test' });
150+
await tool.execute({ query: 'get.Data', path: '/test' });
151+
152+
// 3rd attempt of same concept — concept dedup fires (not circuit breaker)
153+
const r = await tool.execute({ query: 'get_data', path: '/test' });
154+
expect(r).toContain('CONCEPT ALREADY FAILED');
155+
expect(r).not.toContain('CIRCUIT BREAKER');
156+
});
157+
});

0 commit comments

Comments
 (0)