@probelabs/probe 0.6.0-rc303 → 0.6.0-rc305

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,16 +3,17 @@
3
3
  * @module tools/vercel
4
4
  */
5
5
 
6
- import { tool } from 'ai';
6
+ import { tool, generateText } from 'ai';
7
7
  import { search } from '../search.js';
8
8
  import { query } from '../query.js';
9
9
  import { extract } from '../extract.js';
10
10
  import { delegate } from '../delegate.js';
11
11
  import { analyzeAll } from './analyzeAll.js';
12
- import { searchSchema, querySchema, extractSchema, delegateSchema, analyzeAllSchema, searchDescription, searchDelegateDescription, queryDescription, extractDescription, delegateDescription, analyzeAllDescription, parseTargets, parseAndResolvePaths, resolveTargetPath } from './common.js';
12
+ import { searchSchema, searchDelegateSchema, querySchema, extractSchema, delegateSchema, analyzeAllSchema, searchDescription, searchDelegateDescription, queryDescription, extractDescription, delegateDescription, analyzeAllDescription, parseTargets, parseAndResolvePaths, resolveTargetPath } from './common.js';
13
13
  import { existsSync } from 'fs';
14
14
  import { formatErrorForAI } from '../utils/error-types.js';
15
15
  import { annotateOutputWithHashes } from './hashline.js';
16
+ import { createLanguageModel } from '../utils/provider.js';
16
17
  import { truncateForSpan } from '../agent/simpleTelemetry.js';
17
18
 
18
19
  /**
@@ -87,16 +88,128 @@ function autoQuoteSearchTerms(query) {
87
88
  const CODE_SEARCH_SCHEMA = {
88
89
  type: 'object',
89
90
  properties: {
90
- targets: {
91
+ confidence: {
92
+ type: 'string',
93
+ enum: ['high', 'medium', 'low'],
94
+ description: 'How confident you are that these locations answer the question.'
95
+ },
96
+ reason: {
97
+ type: 'string',
98
+ description: 'Brief explanation of confidence level — what was found, partially found, or not found.'
99
+ },
100
+ groups: {
91
101
  type: 'array',
92
- items: { type: 'string' },
93
- description: 'List of file targets like "path/to/file.ext#Symbol" or "path/to/file.ext:line" or "path/to/file.ext:start-end".'
102
+ items: {
103
+ type: 'object',
104
+ properties: {
105
+ reason: {
106
+ type: 'string',
107
+ description: 'Why these files are relevant — what aspect of the question they address (not how the code works).'
108
+ },
109
+ files: {
110
+ type: 'array',
111
+ items: { type: 'string' },
112
+ description: 'File targets like "path/to/file.ext#Symbol" or "path/to/file.ext:10-20".'
113
+ }
114
+ },
115
+ required: ['reason', 'files']
116
+ },
117
+ description: 'Groups of related files, each with a reason explaining why they matter.'
118
+ },
119
+ searches: {
120
+ type: 'array',
121
+ items: {
122
+ type: 'object',
123
+ properties: {
124
+ query: { type: 'string', description: 'The search query used.' },
125
+ path: { type: 'string', description: 'The path searched in.' },
126
+ had_results: { type: 'boolean', description: 'Whether the search returned any results.' }
127
+ },
128
+ required: ['query', 'path', 'had_results']
129
+ },
130
+ description: 'All search queries executed during this session, with their paths and outcomes.'
94
131
  }
95
132
  },
96
- required: ['targets'],
133
+ required: ['confidence', 'reason', 'groups', 'searches'],
97
134
  additionalProperties: false
98
135
  };
99
136
 
137
+ /**
138
+ * LLM-based semantic dedup for delegate queries.
139
+ * Asks the same model to classify a new query against previous ones.
140
+ * Returns: { action: 'allow'|'block'|'rewrite', rewritten?: string, reason: string }
141
+ */
142
+ async function checkDelegateDedup(newQuery, previousQueries, model, debug) {
143
+ if (!model || previousQueries.length === 0) {
144
+ return { action: 'allow', reason: 'no previous queries' };
145
+ }
146
+
147
+ const previousList = previousQueries
148
+ .map((q, i) => {
149
+ let line = `${i + 1}. "${q.query}" (path: ${q.path}, found results: ${q.hadResults})`;
150
+ if (q.reason) line += `\n Outcome: ${q.reason}`;
151
+ if (q.groups && q.groups.length > 0) {
152
+ line += `\n Found: ${q.groups.map(g => g.reason).join('; ')}`;
153
+ }
154
+ return line;
155
+ })
156
+ .join('\n');
157
+
158
+ try {
159
+ const result = await generateText({
160
+ model,
161
+ maxTokens: 150,
162
+ temperature: 0,
163
+ prompt: `You decide if a code search query is redundant given previous queries in the same session.
164
+
165
+ PREVIOUS QUERIES:
166
+ ${previousList}
167
+
168
+ NEW QUERY: "${newQuery}"
169
+
170
+ Respond with exactly one line: ACTION|REASON
171
+ For rewrites: rewrite|REASON|REWRITTEN_QUERY
172
+
173
+ BLOCK when:
174
+ - Same concept, different phrasing: "find X" / "definition of X" / "where is X" / "X implementation" → all the same
175
+ - Synonym or narrower term of a previous query: "dedup" → "duplicate" → "unique" → all the same concept
176
+ - Single generic word that's just a synonym of a previous failed query
177
+ - Query is trying to brute-force the same concept with different keywords after previous failures
178
+
179
+ REWRITE when:
180
+ - Previous query was too narrow and failed, new query targets the same goal but could use a FUNDAMENTALLY different search strategy (e.g. searching for a caller instead of the function name, or searching the config/registration site instead of the implementation)
181
+ - Previous query found WRONG results (e.g. found "FallbackManager" when looking for "dedup logic") — rewrite to target the actual concept more precisely using implementation-level terms
182
+
183
+ ALLOW only when:
184
+ - The new query targets a COMPLETELY DIFFERENT feature, module, or subsystem — not just a different word for the same thing
185
+
186
+ Only BLOCK when you are CERTAIN the queries target the same concept. When uncertain, ALLOW — a missed dedup is cheaper than blocking a valid search.
187
+
188
+ Examples:
189
+ - Prev: "wrapToolWithEmitter" → New: "definition of wrapToolWithEmitter" → block|Same symbol
190
+ - Prev: "search dedup" (no results) → New: "dedup" → block|Synonym of failed query
191
+ - Prev: "dedup" (no results) → New: "duplicate" → block|Synonym of failed query
192
+ - Prev: "dedup" (no results) → New: "unique" → block|Synonym of failed query
193
+ - Prev: "auth middleware" → New: "rate limiting" → allow|Different subsystem
194
+ - Prev: "search dedup" (no results) → New: "previousSearches Map" → rewrite|Searching for implementation detail instead of concept|previousSearches OR searchKey`
195
+ });
196
+
197
+ const line = result.text.trim().split('\n')[0];
198
+ const parts = line.split('|');
199
+ const action = (parts[0] || '').toLowerCase().trim();
200
+
201
+ if (action === 'block') {
202
+ return { action: 'block', reason: parts[1]?.trim() || 'duplicate query' };
203
+ } else if (action === 'rewrite' && parts[2]) {
204
+ return { action: 'rewrite', reason: parts[1]?.trim() || 'refined query', rewritten: parts[2].trim() };
205
+ }
206
+ return { action: 'allow', reason: parts[1]?.trim() || 'new concept' };
207
+ } catch (err) {
208
+ if (debug) console.error('[DEDUP-LLM] Error:', err.message);
209
+ return { action: 'allow', reason: 'dedup check failed, allowing' };
210
+ }
211
+ }
212
+
100
213
  function normalizeTargets(targets) {
101
214
  if (!Array.isArray(targets)) return [];
102
215
  const seen = new Set();
@@ -186,8 +299,13 @@ function fallbackTargetsFromText(text) {
186
299
  return candidates;
187
300
  }
188
301
 
189
- function parseDelegatedTargets(rawResponse) {
190
- if (!rawResponse || typeof rawResponse !== 'string') return [];
302
+ /**
303
+ * Parse the delegate sub-agent's raw response into a structured result.
304
+ * Returns { confidence, groups } when possible, or builds a single-group
305
+ * fallback from legacy { targets: [...] } or plain text.
306
+ */
307
+ function parseDelegatedResponse(rawResponse) {
308
+ if (!rawResponse || typeof rawResponse !== 'string') return null;
191
309
  const trimmed = rawResponse.trim();
192
310
 
193
311
  const tryParse = (text) => {
@@ -207,15 +325,50 @@ function parseDelegatedTargets(rawResponse) {
207
325
  }
208
326
 
209
327
  if (parsed) {
210
- if (Array.isArray(parsed)) {
211
- return normalizeTargets(parsed);
328
+ // New format: { confidence, groups: [{ reason, files }], searches: [...] }
329
+ if (Array.isArray(parsed.groups)) {
330
+ return {
331
+ confidence: parsed.confidence || 'medium',
332
+ reason: parsed.reason || '',
333
+ groups: parsed.groups.map(g => ({
334
+ reason: g.reason || '',
335
+ files: normalizeTargets(g.files || [])
336
+ })).filter(g => g.files.length > 0),
337
+ searches: Array.isArray(parsed.searches) ? parsed.searches : []
338
+ };
212
339
  }
340
+ // Legacy format: { targets: [...] }
213
341
  if (Array.isArray(parsed.targets)) {
214
- return normalizeTargets(parsed.targets);
342
+ const files = normalizeTargets(parsed.targets);
343
+ if (files.length > 0) {
344
+ return { confidence: 'medium', reason: '', groups: [{ reason: 'Search results', files }], searches: [] };
345
+ }
346
+ // Empty targets array — explicitly return null (don't fall through to text fallback)
347
+ return null;
348
+ }
349
+ // Plain array
350
+ if (Array.isArray(parsed)) {
351
+ const files = normalizeTargets(parsed);
352
+ if (files.length > 0) {
353
+ return { confidence: 'medium', reason: '', groups: [{ reason: 'Search results', files }], searches: [] };
354
+ }
355
+ return null;
215
356
  }
216
357
  }
217
358
 
218
- return normalizeTargets(fallbackTargetsFromText(trimmed));
359
+ // Fallback: extract targets from plain text
360
+ const files = normalizeTargets(fallbackTargetsFromText(trimmed));
361
+ if (files.length > 0) {
362
+ return { confidence: 'low', reason: '', groups: [{ reason: 'Search results', files }], searches: [] };
363
+ }
364
+ return null;
365
+ }
366
+
367
+ // Keep backward compat for any other callers
368
+ function parseDelegatedTargets(rawResponse) {
369
+ const result = parseDelegatedResponse(rawResponse);
370
+ if (!result) return [];
371
+ return result.groups.flatMap(g => g.files);
219
372
  }
220
373
 
221
374
  function splitTargetSuffix(target) {
@@ -231,159 +384,78 @@ function splitTargetSuffix(target) {
231
384
  }
232
385
 
233
386
  function buildSearchDelegateTask({ searchQuery, searchPath, exact, language, allowTests }) {
234
- return [
235
- 'You are a code-search subagent. Your job is to find ALL relevant code locations for the given query.',
236
- '',
237
- 'The query may be complex - it could be a natural language question, a multi-part request, or a simple keyword.',
238
- 'Break down complex queries into multiple searches to cover all aspects.',
239
- '',
240
- 'Available tools:',
241
- '- search: Find code matching keywords or patterns. Results are paginated — use nextPage=true when results are relevant to get more. Run multiple searches for different aspects.',
242
- '- extract: Verify code snippets to ensure targets are actually relevant before including them.',
243
- '- listFiles: Understand directory structure to find where relevant code might live.',
244
- '',
245
- 'CRITICAL - How probe search works (do NOT ignore):',
246
- '- By default (exact=false), probe ALREADY handles stemming, case-insensitive matching, and camelCase/snake_case splitting automatically.',
247
- '- Searching "allowed_ips" ALREADY matches "AllowedIPs", "allowedIps", "allowed_ips", etc. Do NOT manually try case/style variations.',
248
- '- Searching "getUserData" ALREADY matches "get", "user", "data" and their variations.',
249
- '- NEVER repeat the same search query — you will get the same results. Changing the path does NOT change this.',
250
- '- NEVER search trivial variations of the same keyword (e.g., AllowedIPs then allowedIps then allowed_ips). This is wasteful probe handles it.',
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.',
268
- '',
269
- 'When to use exact=true:',
270
- '- Use exact=true when searching for a KNOWN symbol name (function, type, variable, struct).',
271
- '- exact=true matches the literal string only no stemming, no splitting.',
272
- '- This is ideal for precise lookups: exact=true "ForwardMessage", exact=true "SessionLimiter", exact=true "ThrottleRetryLimit".',
273
- '- IMPORTANT: Use exact=true when searching for strings containing punctuation, quotes, or empty values.',
274
- ' Default BM25 search strips punctuation and treats quoted empty strings as noise.',
275
- ' Example: searching for \'description: ""\' with exact=false will NOT find empty description fields — it just matches "description".',
276
- ' Use exact=true for literal patterns like \'description: ""\', \'value: \\\'\\\'\', or any YAML/config field with specific punctuation.',
277
- '- Do NOT use exact=true for exploratory/conceptual queries — use the default for those.',
278
- '',
279
- 'Combining searches with OR:',
280
- '- Multiple unquoted words use OR logic: rate limit matches files containing EITHER "rate" OR "limit".',
281
- '- IMPORTANT: Multiple quoted terms use AND logic by default: \'"RateLimit" "middleware"\' requires BOTH in the same file.',
282
- '- To search for ANY of several quoted symbols, use the explicit OR operator: \'"ForwardMessage" OR "SessionLimiter"\'.',
283
- '- Without quotes, camelCase like limitDRL gets split into "limit" + "DRL" not what you want for symbol lookup.',
284
- '- Use OR to search for multiple related symbols in ONE search instead of separate searches.',
285
- '- This is much faster than running separate searches sequentially.',
286
- '- Example: search \'"ForwardMessage" OR "SessionLimiter"\' finds files with either exact symbol in one call.',
287
- '- Example: search \'"limitDRL" OR "doRollingWindowWrite"\' finds both rate limiting functions at once.',
288
- '- Use AND (or just put quoted terms together) when you need both terms in the same file.',
289
- '',
290
- 'Parallel tool calls:',
291
- '- When you need to search for INDEPENDENT concepts, call multiple search tools IN PARALLEL (same response).',
292
- '- Do NOT wait for one search to finish before starting the next if they are independent.',
293
- '- Example: for "rate limiting and session management", call search "rate limiting" AND search "session management" in parallel.',
294
- '- Similarly, call multiple extract tools in parallel when verifying different files.',
295
- '',
296
- 'GOOD search strategy (do this):',
297
- ' Query: "How does authentication work and how are sessions managed?"',
298
- ' → search "authentication" + search "session management" IN PARALLEL (two independent concepts)',
299
- ' Query: "Find the IP allowlist middleware"',
300
- ' → search "allowlist middleware" (one search, probe handles IP/ip/Ip variations)',
301
- ' Query: "Find ForwardMessage and SessionLimiter"',
302
- ' → search \'"ForwardMessage" OR "SessionLimiter"\' (one OR search finds both exact symbols)',
303
- ' OR: search exact=true "ForwardMessage" + search exact=true "SessionLimiter" IN PARALLEL',
304
- ' Query: "Find limitDRL and limitRedis functions"',
305
- ' → search \'"limitDRL" OR "limitRedis"\' (one OR search, quoted to prevent camelCase splitting)',
306
- ' Query: "Find ThrottleRetryLimit usage"',
307
- ' → search exact=true "ThrottleRetryLimit" (one search, if no results the symbol does not exist — stop)',
308
- ' Query: "How does BM25 scoring work with SIMD optimization?"',
309
- ' → search "BM25 scoring" + search "SIMD optimization" IN PARALLEL (two different concepts)',
310
- '',
311
- 'BAD search strategy (never do this):',
312
- ' → search "AllowedIPs" → search "allowedIps" → search "allowed_ips" (WRONG: case/style variations, probe handles them)',
313
- ' → search "limitDRL" → search "LimitDRL" (WRONG: case variation — combine with OR: \'"limitDRL" OR "limitRedis"\')',
314
- ' → search "throttle_retry_limit" after searching "ThrottleRetryLimit" (WRONG: snake_case variation, probe handles it)',
315
- ' → search "ThrottleRetryLimit" path=tyk → search "ThrottleRetryLimit" path=gateway → search "ThrottleRetryLimit" path=apidef (WRONG: same query on different paths — probe searches recursively)',
316
- ' → search "func (k *RateLimitAndQuotaCheck) handleRateLimitFailure" (WRONG: do not search full function signatures, just use exact=true "handleRateLimitFailure")',
317
- ' → search "ForwardMessage" → search "ForwardMessage" → search "ForwardMessage" (WRONG: repeating the exact same query)',
318
- ' → search "authentication" → wait → search "session management" → wait (WRONG: these are independent, run them in parallel)',
319
- '',
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
- '',
335
- 'Keyword tips:',
336
- '- Common programming keywords are filtered as stopwords when unquoted: function, class, return, new, struct, impl, var, let, const, etc.',
337
- '- Avoid searching for these alone — combine with a specific term (e.g., "middleware function" is fine, "function" alone is too generic).',
338
- '- To bypass stopword filtering: wrap terms in quotes ("return", "struct") or set exact=true. Both disable stemming and splitting too.',
339
- '- camelCase terms are split: getUserData becomes "get", "user", "data" — so one search covers all naming styles.',
340
- '- Do NOT search for full function signatures like "func (r *Type) Method(args)". Just search for the method name with exact=true.',
341
- '- Do NOT search for file names (e.g., "sliding_log.go"). Use listFiles to discover files by name.',
342
- '',
343
- 'PAGINATION:',
344
- '- Search results are paginated (~20k tokens per page).',
345
- '- If your search returned relevant files, call the same query with nextPage=true to check for more.',
346
- '- Keep paginating while results stay relevant. Stop when results are off-topic or "All results retrieved".',
347
- '',
348
- 'WHEN TO STOP:',
349
- '- After you have explored the main concept AND related subsystems.',
350
- '- Once you have 5-15 targets covering different aspects of the query.',
351
- '- If you get a "DUPLICATE SEARCH BLOCKED" message, do NOT rephrase the same query — try a FUNDAMENTALLY different approach:',
352
- ' * Switch between exact=true and exact=false',
353
- ' * Search for a broader term and filter results manually',
354
- ' * Use listFiles to browse the directory structure directly',
355
- ' * Look for related/surrounding patterns instead of the exact string',
356
- '- If 2-3 genuinely different search approaches fail, STOP and report what you tried and why it failed.',
357
- ' Do NOT keep trying variations of the same failing concept.',
358
- '',
359
- 'Strategy:',
360
- '1. Analyze the query — identify key concepts, then brainstorm SYNONYMS and alternative terms for each.',
361
- ' Code naming often differs from the concept: "authentication" → verify, credentials, login, auth;',
362
- ' "rate limiting" → throttle, quota, limiter, bucket; "error handling" → catch, recover, panic.',
363
- ' Think about what a developer would NAME the function/struct/variable, not just the concept.',
364
- '2. Run INDEPENDENT searches in PARALLEL — search for the main concept AND synonyms simultaneously.',
365
- ' After each search, check if results are relevant. If yes, call nextPage=true for more results.',
366
- '3. Combine related symbols into OR searches: \'"symbolA" OR "symbolB"\' finds files with either.',
367
- '4. For known symbol names use exact=true. For concepts use default (exact=false).',
368
- '5. After your first round of searches, READ the extracted code and look for connected code:',
369
- ' - Function calls to other important functions → include those targets.',
370
- ' - Type references and imports → include type definitions.',
371
- ' - Registered handlers/middleware → include all registered items.',
372
- '6. If a search returns results, use extract to verify relevance. Run multiple extracts in parallel too.',
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.',
374
- '8. Once you have enough targets (typically 5-15), output your final JSON answer immediately.',
375
- '',
376
- `Query: ${searchQuery}`,
377
- `Search path(s): ${searchPath}`,
378
- `Options: exact=${exact ? 'true' : 'false'}, language=${language || 'auto'}, allow_tests=${allowTests ? 'true' : 'false'}.`,
379
- '',
380
- 'Return ONLY valid JSON: {"targets": ["path/to/file.ext#Symbol", "path/to/file.ext:line", "path/to/file.ext:start-end"]}',
381
- 'IMPORTANT: Use ABSOLUTE file paths in targets (e.g., "/full/path/to/file.ext#Symbol"). If you only have relative paths, make them relative to the search path above.',
382
- 'Prefer #Symbol when a function/class name is clear; otherwise use line numbers.',
383
- 'Deduplicate targets. Do NOT explain or answer - ONLY return the JSON targets.',
384
- '',
385
- 'Remember: if your search returned relevant results, use nextPage=true to check for more before outputting.'
386
- ].join('\n');
387
+ return `<role>
388
+ You are a code-location subagent. Your job is to find WHERE relevant code lives for the given question.
389
+ You are NOT answering the question — you are finding the code locations that would help answer it.
390
+ </role>
391
+
392
+ <task>
393
+ <question>${searchQuery}</question>
394
+ <search-path>${searchPath}</search-path>
395
+ <options language="${language || 'auto'}" allow_tests="${allowTests ? 'true' : 'false'}" />
396
+ </task>
397
+
398
+ <tools>
399
+ <tool name="search">
400
+ Find code matching keywords or patterns. Results are paginated use nextPage=true when results are relevant to get more.
401
+ </tool>
402
+ <tool name="extract">
403
+ Read code to verify a file is actually relevant before including it.
404
+ </tool>
405
+ <tool name="listFiles">
406
+ Browse directory structure to discover where code might live.
407
+ </tool>
408
+ </tools>
409
+
410
+ <search-engine-behavior>
411
+ - Probe handles stemming, case-insensitive matching, and camelCase/snake_case splitting automatically.
412
+ - "allowed_ips" ALREADY matches "AllowedIPs", "allowedIps", etc. Do NOT try case/style variations.
413
+ - NEVER repeat the same search query — you will get the same results.
414
+ - If a search returns no results at workspace root, the term does not exist. Move on.
415
+ - If a search returns no results in a subfolder, try the workspace root or a different directory.
416
+ - Use exact=true for known symbol names. Use default for conceptual/exploratory queries.
417
+ - Combine related symbols with OR: "SymbolA" OR "SymbolB" finds files with either.
418
+ - Run INDEPENDENT searches in PARALLEL do not wait between unrelated searches.
419
+ </search-engine-behavior>
420
+
421
+ <strategy>
422
+ 1. Analyze the question — identify key concepts and brainstorm what a developer would NAME the relevant code.
423
+ 2. Start your first search with the FULL search-path provided above. Do NOT narrow to a subdirectory on first try the code may live anywhere in the tree.
424
+ 3. Search for the main concept and synonyms in parallel.
425
+ 4. Use extract to verify relevance skim the code to confirm it ACTUALLY relates to the question.
426
+ 5. Follow the trail: if you find a function, look for its callers, type definitions, and registered handlers.
427
+ 6. Group your findings by WHY they are relevant (not by how you found them).
428
+ </strategy>
429
+
430
+ <relevance-filtering priority="critical">
431
+ - Only include files you have VERIFIED are relevant by reading them with extract.
432
+ - Do NOT include files just because they matched a keyword — confirm the match is meaningful.
433
+ - A file that mentions "session" in a comment is NOT relevant to "How do sessions work?" look for the actual implementation.
434
+ - Fewer verified-relevant files are far more valuable than many unverified keyword matches.
435
+ - If a file is tangentially related but not core to the question, leave it out.
436
+ - If NO files are truly relevant, return EMPTY groups with confidence "low". An honest empty result is far better than a wrong result. Never fill groups with loosely related files just to have something.
437
+ </relevance-filtering>
438
+
439
+ <stop-conditions>
440
+ - Once you have found locations covering the main concept and related subsystems.
441
+ - If 2-3 different search approaches fail, stop and report what you have.
442
+ - Do NOT keep trying quote/syntax variations of the same failing keyword.
443
+ </stop-conditions>
444
+
445
+ <on-iteration-limit>
446
+ If you run out of tool iterations, you MUST still output your JSON response with whatever you found so far.
447
+ Set confidence to "low" if your search was incomplete.
448
+ Include ALL files you verified as relevant, even if coverage is partial.
449
+ The "searches" field helps the caller understand what was attempted.
450
+ </on-iteration-limit>
451
+
452
+ <output-rules>
453
+ - Return ONLY valid JSON matching the schema. No markdown, no explanation.
454
+ - ONLY include files you have verified are relevant. No noise.
455
+ - Group files by RELEVANCE to the question, not by search query.
456
+ - Use ABSOLUTE file paths. Prefer #Symbol for functions/classes; otherwise use line ranges.
457
+ - Deduplicate files across groups.
458
+ </output-rules>`;
387
459
  }
388
460
 
389
461
  /**
@@ -425,6 +497,13 @@ export const searchTool = (options = {}) => {
425
497
  const failedConcepts = new Map(); // normalizedKey → count
426
498
  const MAX_PAGES_PER_QUERY = 3;
427
499
 
500
+ // Track delegated searches at the PARENT level to prevent the pro model from
501
+ // spawning redundant delegates for the same concept. Each delegate is expensive
502
+ // (full flash agent session), so blocking repeats saves minutes.
503
+ // LLM-based semantic dedup replaces deterministic normalization for delegates.
504
+ const previousDelegations = []; // { query: string, path: string, hadResults: boolean }
505
+ let cachedDedupModel = undefined; // lazily initialized
506
+
428
507
  /**
429
508
  * Normalize a search query to detect syntax-level duplicates.
430
509
  * Strips quotes, dots, underscores/hyphens, and lowercases.
@@ -436,6 +515,9 @@ export const searchTool = (options = {}) => {
436
515
  if (!query) return '';
437
516
  return query
438
517
  .replace(/^["']|["']$/g, '') // strip outer quotes
518
+ // Strip filler prefixes: "definition of X", "find X", "where is X", etc.
519
+ .replace(/^(definition\s+of|implementation\s+of|usage\s+of|find|where\s+is|how\s+does|locate|show\s+me|get|look\s+for)\s+/i, '')
520
+ .replace(/^["']|["']$/g, '') // strip quotes again after prefix removal
439
521
  .replace(/\./g, '') // "ctx.GetData" → "ctxGetData"
440
522
  .replace(/[_\-\s]+/g, '') // strip underscores/hyphens/spaces
441
523
  .toLowerCase()
@@ -447,7 +529,7 @@ export const searchTool = (options = {}) => {
447
529
  description: searchDelegate
448
530
  ? searchDelegateDescription
449
531
  : searchDescription,
450
- inputSchema: searchSchema,
532
+ inputSchema: searchDelegate ? searchDelegateSchema : searchSchema,
451
533
  execute: async ({ query: searchQuery, path, allow_tests, exact, maxTokens: paramMaxTokens, language, session, nextPage, workingDirectory }) => {
452
534
  // Auto-quote mixed-case and underscore terms to prevent unwanted stemming/splitting
453
535
  // Skip when exact=true since that already preserves the literal string
@@ -508,7 +590,8 @@ export const searchTool = (options = {}) => {
508
590
  // Block duplicate non-paginated searches (models sometimes repeat the exact same call)
509
591
  // Allow pagination: only nextPage=true is a legitimate repeat of the same query
510
592
  // Include path in dedup key so same query across different repos is allowed (#520)
511
- const searchKey = `${searchPath}::${searchQuery}::${exact || false}`;
593
+ const searchKey = `${searchPath}::${searchQuery}::${exact || false}::${language || ''}`;
594
+ let circuitBreakerWarning = '';
512
595
  if (!nextPage) {
513
596
  if (previousSearches.has(searchKey)) {
514
597
  const blockCount = (dupBlockCounts.get(searchKey) || 0) + 1;
@@ -548,16 +631,20 @@ export const searchTool = (options = {}) => {
548
631
  }
549
632
 
550
633
  // Circuit breaker: too many consecutive no-result searches means the model
551
- // is stuck in a loop guessing names that don't exist
634
+ // is stuck in a loop guessing names that don't exist.
635
+ // Not permanent: allow the search through but prepend a strong warning.
636
+ // If it succeeds, consecutiveNoResults resets to 0 (line ~598).
637
+ // If it fails, the counter keeps climbing and subsequent attempts
638
+ // get increasingly stern warnings.
552
639
  if (consecutiveNoResults >= MAX_CONSECUTIVE_NO_RESULTS) {
553
640
  if (debug) {
554
- console.error(`[CIRCUIT-BREAKER] ${consecutiveNoResults} consecutive no-result searches, blocking: "${searchQuery}"`);
641
+ console.error(`[CIRCUIT-BREAKER] ${consecutiveNoResults} consecutive no-result searches, warning: "${searchQuery}"`);
555
642
  }
556
643
  const isSubfolderCB = path && path !== effectiveSearchCwd && path !== '.';
557
644
  const cbScopeHint = isSubfolderCB
558
- ? `\n- You have been searching in "${path}" — try searching from the workspace root or a different directory`
645
+ ? ` You have been searching in "${path}" — consider searching from the workspace root or a different directory.`
559
646
  : '';
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.`;
647
+ circuitBreakerWarning = `\n\n⚠️ CIRCUIT BREAKER: Your last ${consecutiveNoResults} searches ALL returned no results.${cbScopeHint} You MUST change your approach: use extract on files you already found, use listFiles to browse directories, or provide your final answer. Guessing names will not help.`;
561
648
  }
562
649
  } else {
563
650
  // Cap pagination to prevent runaway page-through of broad queries
@@ -583,10 +670,10 @@ export const searchTool = (options = {}) => {
583
670
  }
584
671
  // Append contextual hint for ticket/issue ID queries
585
672
  if (/^[A-Z]+-\d+$/.test(searchQuery.trim()) || /^[A-Z]+-\d+$/.test(searchQuery.replace(/"/g, '').trim())) {
586
- 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).';
673
+ 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).' + circuitBreakerWarning;
587
674
  }
588
675
  // Add a hint when approaching the circuit breaker threshold
589
- if (consecutiveNoResults >= MAX_CONSECUTIVE_NO_RESULTS - 1) {
676
+ if (consecutiveNoResults >= MAX_CONSECUTIVE_NO_RESULTS - 1 && !circuitBreakerWarning) {
590
677
  const isSubfolderWarn = path && path !== effectiveSearchCwd && path !== '.';
591
678
  const warnScopeHint = isSubfolderWarn
592
679
  ? ` You are searching in "${path}" — consider searching from the workspace root or a different directory.`
@@ -603,7 +690,7 @@ export const searchTool = (options = {}) => {
603
690
  if (options.fileTracker && typeof result === 'string') {
604
691
  options.fileTracker.trackFilesFromOutput(result, effectiveSearchCwd).catch(() => {});
605
692
  }
606
- return result;
693
+ return typeof result === 'string' ? result + circuitBreakerWarning : result;
607
694
  } catch (error) {
608
695
  console.error('Error executing search command:', error);
609
696
  const formatted = formatErrorForAI(error);
@@ -614,13 +701,76 @@ export const searchTool = (options = {}) => {
614
701
  }
615
702
  }
616
703
 
704
+ // ── Delegate-level semantic dedup ────────────────────────────
705
+ // Each delegate is a full flash agent session (minutes, not seconds).
706
+ // Use LLM to detect semantic duplicates and suggest rewrites.
707
+ // Compare against ALL previous delegations (not filtered by path) because
708
+ // the parent model often narrows the path while asking the same concept
709
+ // (e.g., "dedup" at /src → "deduplicate" at /src/search.js).
710
+ const delegatePath = searchPath || '';
711
+
712
+ let effectiveQuery = searchQuery;
713
+
714
+ if (previousDelegations.length > 0) {
715
+ // Lazily create the dedup model (same provider/model as delegate)
716
+ if (cachedDedupModel === undefined) {
717
+ const dedupProvider = options.searchDelegateProvider || process.env.PROBE_SEARCH_DELEGATE_PROVIDER || options.provider || process.env.FORCE_PROVIDER || null;
718
+ const dedupModelName = options.searchDelegateModel || process.env.PROBE_SEARCH_DELEGATE_MODEL || options.model || process.env.MODEL_NAME || null;
719
+ if (debug) {
720
+ console.error(`[DEDUP-LLM] Creating model: provider=${dedupProvider}, model=${dedupModelName}`);
721
+ }
722
+ cachedDedupModel = await createLanguageModel(dedupProvider, dedupModelName);
723
+ if (debug) {
724
+ console.error(`[DEDUP-LLM] Model created: ${cachedDedupModel ? 'success' : 'null'}`);
725
+ }
726
+ }
727
+
728
+ const dedupSpanAttrs = {
729
+ 'dedup.query': searchQuery,
730
+ 'dedup.previous_count': String(previousDelegations.length),
731
+ 'dedup.previous_queries': previousDelegations.map(d => d.query).join(' | '),
732
+ };
733
+
734
+ const dedup = options.tracer?.withSpan
735
+ ? await options.tracer.withSpan('search.delegate.dedup', async () => {
736
+ return await checkDelegateDedup(searchQuery, previousDelegations, cachedDedupModel, debug);
737
+ }, dedupSpanAttrs, (span, result) => {
738
+ span.setAttributes({
739
+ 'dedup.action': result.action,
740
+ 'dedup.reason': result.reason || '',
741
+ 'dedup.rewritten': result.rewritten || '',
742
+ });
743
+ })
744
+ : await checkDelegateDedup(searchQuery, previousDelegations, cachedDedupModel, debug);
745
+
746
+ if (debug) {
747
+ console.error(`[DEDUP-LLM] Query: "${searchQuery}" → ${dedup.action}: ${dedup.reason}${dedup.rewritten ? ` → "${dedup.rewritten}"` : ''}`);
748
+ }
749
+
750
+ if (dedup.action === 'block') {
751
+ const prevQueries = previousDelegations.map(d => `"${d.query}"`).join(', ');
752
+ return `DELEGATE BLOCKED: "${searchQuery}" is semantically duplicate of previous delegation(s) [${prevQueries}]. ${dedup.reason}\n\nDo NOT re-delegate the same concept. Use extract() on files already found, or synthesize your answer from existing results.`;
753
+ }
754
+
755
+ if (dedup.action === 'rewrite' && dedup.rewritten) {
756
+ effectiveQuery = dedup.rewritten;
757
+ if (debug) {
758
+ console.error(`[DEDUP-LLM] Rewritten query: "${searchQuery}" → "${effectiveQuery}"`);
759
+ }
760
+ }
761
+ }
762
+
763
+ // Record this delegation
764
+ const delegationRecord = { query: effectiveQuery, path: delegatePath, hadResults: false };
765
+ previousDelegations.push(delegationRecord);
766
+
617
767
  try {
618
768
  if (debug) {
619
- console.error(`Delegating search with query: "${searchQuery}", path: "${searchPath}"`);
769
+ console.error(`Delegating search with query: "${effectiveQuery}", path: "${searchPath}"${effectiveQuery !== searchQuery ? ` (rewritten from: "${searchQuery}")` : ''}`);
620
770
  }
621
771
 
622
772
  const delegateTask = buildSearchDelegateTask({
623
- searchQuery,
773
+ searchQuery: effectiveQuery,
624
774
  searchPath,
625
775
  exact,
626
776
  language,
@@ -650,20 +800,37 @@ export const searchTool = (options = {}) => {
650
800
  const delegateResult = options.tracer?.withSpan
651
801
  ? await options.tracer.withSpan('search.delegate', runDelegation, {
652
802
  'search.query': searchQuery,
653
- 'search.path': searchPath
803
+ 'search.path': searchPath,
804
+ ...(effectiveQuery !== searchQuery ? { 'search.query.rewritten': effectiveQuery } : {})
654
805
  }, (span, result) => {
655
- const text = typeof result === 'string' ? result : '';
806
+ const text = typeof result === 'string' ? result : JSON.stringify(result) || '';
807
+ if (debug) console.error(`[search-delegate] onResult: type=${typeof result}, length=${text.length}`);
656
808
  span.setAttributes({
657
809
  'search.delegate.output': truncateForSpan(text),
658
- 'search.delegate.output_length': text.length
810
+ 'search.delegate.output_length': String(text.length)
659
811
  });
660
812
  })
661
813
  : await runDelegation();
662
814
 
663
- const targets = parseDelegatedTargets(delegateResult);
664
- if (!targets.length) {
815
+ const structured = parseDelegatedResponse(delegateResult);
816
+ // Update delegation tracking with outcome (feeds into LLM dedup context)
817
+ if (delegationRecord && structured) {
818
+ delegationRecord.hadResults = structured.groups.length > 0;
819
+ delegationRecord.reason = structured.reason || '';
820
+ delegationRecord.groups = structured.groups.map(g => ({ reason: g.reason }));
821
+ }
822
+ if (!structured || structured.groups.length === 0) {
823
+ // If the delegate explicitly concluded nothing was found (low confidence + reason),
824
+ // return that verdict instead of falling back to raw search which would
825
+ // return tangentially related results and mislead the parent agent.
826
+ if (structured && structured.confidence === 'low' && structured.reason) {
827
+ if (debug) {
828
+ console.error(`Delegated search explicitly found nothing: ${structured.reason}`);
829
+ }
830
+ return `NOT FOUND: The search delegate thoroughly searched for "${searchQuery}" and concluded: ${structured.reason}\n\nDo NOT search for analogies or loosely related concepts. If the feature does not exist in the codebase, say so in your final answer.`;
831
+ }
665
832
  if (debug) {
666
- console.error('Delegated search returned no targets; falling back to raw search');
833
+ console.error('Delegated search returned no results; falling back to raw search');
667
834
  }
668
835
  const fallbackResult = maybeAnnotate(await runRawSearch());
669
836
  if (options.fileTracker && typeof fallbackResult === 'string') {
@@ -672,78 +839,52 @@ export const searchTool = (options = {}) => {
672
839
  return fallbackResult;
673
840
  }
674
841
 
675
- // The delegate runs from workspace root (allowedFolders[0] or cwd), NOT from searchPaths[0].
676
- // It returns paths relative to that workspace root. Resolve against the same base.
842
+ // Resolve and validate file paths in each group
677
843
  const delegateBase = options.allowedFolders?.[0] || options.cwd || '.';
678
844
  const resolutionBase = searchPaths[0] || options.cwd || '.';
679
- const resolvedTargets = targets.map(target => resolveTargetPath(target, delegateBase));
845
+ const wsPrefix = resolutionBase.endsWith('/') ? resolutionBase : resolutionBase + '/';
680
846
 
681
- // Auto-fix: detect and repair invalid paths (doubled segments, AI hallucinations)
682
- const validatedTargets = [];
683
- for (const target of resolvedTargets) {
684
- const { filePart, suffix } = splitTargetSuffix(target);
847
+ for (const group of structured.groups) {
848
+ group.files = group.files
849
+ .map(target => resolveTargetPath(target, delegateBase))
850
+ .map(target => {
851
+ const { filePart, suffix } = splitTargetSuffix(target);
685
852
 
686
- // 1. Path exists as-is
687
- if (existsSync(filePart)) {
688
- validatedTargets.push(target);
689
- continue;
690
- }
853
+ // 1. Path exists as-is
854
+ if (existsSync(filePart)) return target;
691
855
 
692
- // 2. Detect doubled directory segments: /ws/proj/proj/src → /ws/proj/src
693
- let fixed = false;
694
- const parts = filePart.split('/').filter(Boolean);
695
- for (let i = 0; i < parts.length - 1; i++) {
696
- if (parts[i] === parts[i + 1]) {
697
- const candidate = '/' + [...parts.slice(0, i), ...parts.slice(i + 1)].join('/');
698
- if (existsSync(candidate)) {
699
- validatedTargets.push(candidate + suffix);
700
- if (debug) console.error(`[search-delegate] Fixed doubled path segment: ${filePart} → ${candidate}`);
701
- fixed = true;
702
- break;
856
+ // 2. Fix doubled directory segments: /ws/proj/proj/src → /ws/proj/src
857
+ const parts = filePart.split('/').filter(Boolean);
858
+ for (let i = 0; i < parts.length - 1; i++) {
859
+ if (parts[i] === parts[i + 1]) {
860
+ const candidate = '/' + [...parts.slice(0, i), ...parts.slice(i + 1)].join('/');
861
+ if (existsSync(candidate)) {
862
+ if (debug) console.error(`[search-delegate] Fixed doubled path: ${filePart} → ${candidate}`);
863
+ return candidate + suffix;
864
+ }
865
+ }
703
866
  }
704
- }
705
- }
706
- if (fixed) continue;
707
-
708
- // 3. Try resolving against alternative bases (searchPaths[0], cwd)
709
- for (const altBase of [resolutionBase, options.cwd].filter(Boolean)) {
710
- if (altBase === delegateBase) continue;
711
- const altResolved = resolveTargetPath(target, altBase);
712
- const { filePart: altFile } = splitTargetSuffix(altResolved);
713
- if (existsSync(altFile)) {
714
- validatedTargets.push(altResolved);
715
- if (debug) console.error(`[search-delegate] Resolved with alt base: ${filePart} → ${altFile}`);
716
- fixed = true;
717
- break;
718
- }
719
- }
720
- if (fixed) continue;
721
867
 
722
- // 4. Keep target anyway (probe binary will report the error)
723
- // but log a warning
724
- if (debug) console.error(`[search-delegate] Warning: target may not exist: ${filePart}`);
725
- validatedTargets.push(target);
726
- }
727
-
728
- const extractOptions = {
729
- files: validatedTargets,
730
- cwd: resolutionBase,
731
- allowTests: allow_tests ?? true
732
- };
733
-
734
- if (outline) {
735
- extractOptions.format = 'xml';
736
- }
737
-
738
- const extractResult = await extract(extractOptions);
868
+ // 3. Try alternative bases
869
+ for (const altBase of [resolutionBase, options.cwd].filter(Boolean)) {
870
+ if (altBase === delegateBase) continue;
871
+ const altResolved = resolveTargetPath(target, altBase);
872
+ const { filePart: altFile } = splitTargetSuffix(altResolved);
873
+ if (existsSync(altFile)) {
874
+ if (debug) console.error(`[search-delegate] Resolved with alt base: ${filePart} → ${altFile}`);
875
+ return altResolved;
876
+ }
877
+ }
739
878
 
740
- // Strip workspace root prefix from extract output so paths are relative
741
- if (resolutionBase && typeof extractResult === 'string') {
742
- const wsPrefix = resolutionBase.endsWith('/') ? resolutionBase : resolutionBase + '/';
743
- return maybeAnnotate(extractResult.split(wsPrefix).join(''));
879
+ if (debug) console.error(`[search-delegate] Warning: target may not exist: ${filePart}`);
880
+ return target;
881
+ })
882
+ // Strip workspace prefix to make paths relative
883
+ .map(target => target.split(wsPrefix).join(''));
744
884
  }
745
885
 
746
- return maybeAnnotate(extractResult);
886
+ // Return structured JSON for the parent AI to decide what to extract
887
+ return JSON.stringify(structured, null, 2);
747
888
  } catch (error) {
748
889
  console.error('Delegated search failed, falling back to raw search:', error);
749
890
  try {