@probelabs/probe 0.6.0-rc302 → 0.6.0-rc304

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,129 +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
- '- 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.',
253
- '',
254
- 'When to use exact=true:',
255
- '- Use exact=true when searching for a KNOWN symbol name (function, type, variable, struct).',
256
- '- exact=true matches the literal string only — no stemming, no splitting.',
257
- '- This is ideal for precise lookups: exact=true "ForwardMessage", exact=true "SessionLimiter", exact=true "ThrottleRetryLimit".',
258
- '- IMPORTANT: Use exact=true when searching for strings containing punctuation, quotes, or empty values.',
259
- ' Default BM25 search strips punctuation and treats quoted empty strings as noise.',
260
- ' Example: searching for \'description: ""\' with exact=false will NOT find empty description fields — it just matches "description".',
261
- ' Use exact=true for literal patterns like \'description: ""\', \'value: \\\'\\\'\', or any YAML/config field with specific punctuation.',
262
- '- Do NOT use exact=true for exploratory/conceptual queries use the default for those.',
263
- '',
264
- 'Combining searches with OR:',
265
- '- Multiple unquoted words use OR logic: rate limit matches files containing EITHER "rate" OR "limit".',
266
- '- IMPORTANT: Multiple quoted terms use AND logic by default: \'"RateLimit" "middleware"\' requires BOTH in the same file.',
267
- '- To search for ANY of several quoted symbols, use the explicit OR operator: \'"ForwardMessage" OR "SessionLimiter"\'.',
268
- '- Without quotes, camelCase like limitDRL gets split into "limit" + "DRL" — not what you want for symbol lookup.',
269
- '- Use OR to search for multiple related symbols in ONE search instead of separate searches.',
270
- '- This is much faster than running separate searches sequentially.',
271
- '- Example: search \'"ForwardMessage" OR "SessionLimiter"\' finds files with either exact symbol in one call.',
272
- '- Example: search \'"limitDRL" OR "doRollingWindowWrite"\' finds both rate limiting functions at once.',
273
- '- Use AND (or just put quoted terms together) when you need both terms in the same file.',
274
- '',
275
- 'Parallel tool calls:',
276
- '- When you need to search for INDEPENDENT concepts, call multiple search tools IN PARALLEL (same response).',
277
- '- Do NOT wait for one search to finish before starting the next if they are independent.',
278
- '- Example: for "rate limiting and session management", call search "rate limiting" AND search "session management" in parallel.',
279
- '- Similarly, call multiple extract tools in parallel when verifying different files.',
280
- '',
281
- 'GOOD search strategy (do this):',
282
- ' Query: "How does authentication work and how are sessions managed?"',
283
- ' → search "authentication" + search "session management" IN PARALLEL (two independent concepts)',
284
- ' Query: "Find the IP allowlist middleware"',
285
- ' → search "allowlist middleware" (one search, probe handles IP/ip/Ip variations)',
286
- ' Query: "Find ForwardMessage and SessionLimiter"',
287
- ' → search \'"ForwardMessage" OR "SessionLimiter"\' (one OR search finds both exact symbols)',
288
- ' OR: search exact=true "ForwardMessage" + search exact=true "SessionLimiter" IN PARALLEL',
289
- ' Query: "Find limitDRL and limitRedis functions"',
290
- ' → search \'"limitDRL" OR "limitRedis"\' (one OR search, quoted to prevent camelCase splitting)',
291
- ' Query: "Find ThrottleRetryLimit usage"',
292
- ' → search exact=true "ThrottleRetryLimit" (one search, if no results the symbol does not exist — stop)',
293
- ' Query: "How does BM25 scoring work with SIMD optimization?"',
294
- ' → search "BM25 scoring" + search "SIMD optimization" IN PARALLEL (two different concepts)',
295
- '',
296
- 'BAD search strategy (never do this):',
297
- ' → search "AllowedIPs" → search "allowedIps" → search "allowed_ips" (WRONG: case/style variations, probe handles them)',
298
- ' → search "limitDRL" → search "LimitDRL" (WRONG: case variation — combine with OR: \'"limitDRL" OR "limitRedis"\')',
299
- ' → search "throttle_retry_limit" after searching "ThrottleRetryLimit" (WRONG: snake_case variation, probe handles it)',
300
- ' → search "ThrottleRetryLimit" path=tyk search "ThrottleRetryLimit" path=gateway search "ThrottleRetryLimit" path=apidef (WRONG: same query on different paths — probe searches recursively)',
301
- ' → search "func (k *RateLimitAndQuotaCheck) handleRateLimitFailure" (WRONG: do not search full function signatures, just use exact=true "handleRateLimitFailure")',
302
- ' → search "ForwardMessage" search "ForwardMessage" search "ForwardMessage" (WRONG: repeating the exact same query)',
303
- ' → search "authentication" wait search "session management" wait (WRONG: these are independent, run them in parallel)',
304
- '',
305
- 'Keyword tips:',
306
- '- Common programming keywords are filtered as stopwords when unquoted: function, class, return, new, struct, impl, var, let, const, etc.',
307
- '- Avoid searching for these alone — combine with a specific term (e.g., "middleware function" is fine, "function" alone is too generic).',
308
- '- To bypass stopword filtering: wrap terms in quotes ("return", "struct") or set exact=true. Both disable stemming and splitting too.',
309
- '- camelCase terms are split: getUserData becomes "get", "user", "data" — so one search covers all naming styles.',
310
- '- Do NOT search for full function signatures like "func (r *Type) Method(args)". Just search for the method name with exact=true.',
311
- '- Do NOT search for file names (e.g., "sliding_log.go"). Use listFiles to discover files by name.',
312
- '',
313
- 'PAGINATION:',
314
- '- Search results are paginated (~20k tokens per page).',
315
- '- If your search returned relevant files, call the same query with nextPage=true to check for more.',
316
- '- Keep paginating while results stay relevant. Stop when results are off-topic or "All results retrieved".',
317
- '',
318
- 'WHEN TO STOP:',
319
- '- After you have explored the main concept AND related subsystems.',
320
- '- Once you have 5-15 targets covering different aspects of the query.',
321
- '- If you get a "DUPLICATE SEARCH BLOCKED" message, do NOT rephrase the same query — try a FUNDAMENTALLY different approach:',
322
- ' * Switch between exact=true and exact=false',
323
- ' * Search for a broader term and filter results manually',
324
- ' * Use listFiles to browse the directory structure directly',
325
- ' * Look for related/surrounding patterns instead of the exact string',
326
- '- If 2-3 genuinely different search approaches fail, STOP and report what you tried and why it failed.',
327
- ' Do NOT keep trying variations of the same failing concept.',
328
- '',
329
- 'Strategy:',
330
- '1. Analyze the query — identify key concepts, then brainstorm SYNONYMS and alternative terms for each.',
331
- ' Code naming often differs from the concept: "authentication" → verify, credentials, login, auth;',
332
- ' "rate limiting" → throttle, quota, limiter, bucket; "error handling" → catch, recover, panic.',
333
- ' Think about what a developer would NAME the function/struct/variable, not just the concept.',
334
- '2. Run INDEPENDENT searches in PARALLEL — search for the main concept AND synonyms simultaneously.',
335
- ' After each search, check if results are relevant. If yes, call nextPage=true for more results.',
336
- '3. Combine related symbols into OR searches: \'"symbolA" OR "symbolB"\' finds files with either.',
337
- '4. For known symbol names use exact=true. For concepts use default (exact=false).',
338
- '5. After your first round of searches, READ the extracted code and look for connected code:',
339
- ' - Function calls to other important functions → include those targets.',
340
- ' - Type references and imports → include type definitions.',
341
- ' - Registered handlers/middleware → include all registered items.',
342
- '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.',
344
- '8. Once you have enough targets (typically 5-15), output your final JSON answer immediately.',
345
- '',
346
- `Query: ${searchQuery}`,
347
- `Search path(s): ${searchPath}`,
348
- `Options: exact=${exact ? 'true' : 'false'}, language=${language || 'auto'}, allow_tests=${allowTests ? 'true' : 'false'}.`,
349
- '',
350
- 'Return ONLY valid JSON: {"targets": ["path/to/file.ext#Symbol", "path/to/file.ext:line", "path/to/file.ext:start-end"]}',
351
- '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.',
352
- 'Prefer #Symbol when a function/class name is clear; otherwise use line numbers.',
353
- 'Deduplicate targets. Do NOT explain or answer - ONLY return the JSON targets.',
354
- '',
355
- 'Remember: if your search returned relevant results, use nextPage=true to check for more before outputting.'
356
- ].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>`;
357
459
  }
358
460
 
359
461
  /**
@@ -388,14 +490,46 @@ export const searchTool = (options = {}) => {
388
490
  const dupBlockCounts = new Map();
389
491
  // Track pagination counts per query to cap runaway pagination
390
492
  const paginationCounts = new Map();
493
+ // Track consecutive no-result searches (circuit breaker)
494
+ let consecutiveNoResults = 0;
495
+ const MAX_CONSECUTIVE_NO_RESULTS = 4;
496
+ // Track normalized query concepts for fuzzy dedup (catches quote/syntax variations)
497
+ const failedConcepts = new Map(); // normalizedKey → count
391
498
  const MAX_PAGES_PER_QUERY = 3;
392
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
+
507
+ /**
508
+ * Normalize a search query to detect syntax-level duplicates.
509
+ * Strips quotes, dots, underscores/hyphens, and lowercases.
510
+ * "ctxGetData", "ctx.GetData", "ctx_get_data" all → "ctxgetdata"
511
+ * Note: does NOT strip language keywords (func, type) — those change search
512
+ * semantics and are already handled as stopwords by the Rust search engine.
513
+ */
514
+ function normalizeQueryConcept(query) {
515
+ if (!query) return '';
516
+ return query
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
521
+ .replace(/\./g, '') // "ctx.GetData" → "ctxGetData"
522
+ .replace(/[_\-\s]+/g, '') // strip underscores/hyphens/spaces
523
+ .toLowerCase()
524
+ .trim();
525
+ }
526
+
393
527
  return tool({
394
528
  name: 'search',
395
529
  description: searchDelegate
396
530
  ? searchDelegateDescription
397
531
  : searchDescription,
398
- inputSchema: searchSchema,
532
+ inputSchema: searchDelegate ? searchDelegateSchema : searchSchema,
399
533
  execute: async ({ query: searchQuery, path, allow_tests, exact, maxTokens: paramMaxTokens, language, session, nextPage, workingDirectory }) => {
400
534
  // Auto-quote mixed-case and underscore terms to prevent unwanted stemming/splitting
401
535
  // Skip when exact=true since that already preserves the literal string
@@ -456,7 +590,8 @@ export const searchTool = (options = {}) => {
456
590
  // Block duplicate non-paginated searches (models sometimes repeat the exact same call)
457
591
  // Allow pagination: only nextPage=true is a legitimate repeat of the same query
458
592
  // Include path in dedup key so same query across different repos is allowed (#520)
459
- const searchKey = `${searchPath}::${searchQuery}::${exact || false}`;
593
+ const searchKey = `${searchPath}::${searchQuery}::${exact || false}::${language || ''}`;
594
+ let circuitBreakerWarning = '';
460
595
  if (!nextPage) {
461
596
  if (previousSearches.has(searchKey)) {
462
597
  const blockCount = (dupBlockCounts.get(searchKey) || 0) + 1;
@@ -478,6 +613,39 @@ export const searchTool = (options = {}) => {
478
613
  }
479
614
  previousSearches.set(searchKey, { hadResults: false });
480
615
  paginationCounts.set(searchKey, 0);
616
+
617
+ // Fuzzy concept dedup: catch quote/syntax variations of the same failed concept
618
+ // e.g., "func ctxGetData", "ctxGetData", "ctx.GetData" all normalize to "ctxgetdata"
619
+ const normalizedKey = `${searchPath}::${normalizeQueryConcept(searchQuery)}`;
620
+ if (failedConcepts.has(normalizedKey) && failedConcepts.get(normalizedKey) >= 2) {
621
+ const conceptCount = failedConcepts.get(normalizedKey) + 1;
622
+ failedConcepts.set(normalizedKey, conceptCount);
623
+ if (debug) {
624
+ console.error(`[CONCEPT-DEDUP] Blocked variation of failed concept (${conceptCount}x): "${searchQuery}" normalized to "${normalizeQueryConcept(searchQuery)}"`);
625
+ }
626
+ const isSubfolder = path && path !== effectiveSearchCwd && path !== '.';
627
+ const scopeHint = isSubfolder
628
+ ? `\n- Try searching from the workspace root (omit the path parameter) — the term may exist in a different directory`
629
+ : `\n- The term does not exist in this codebase at any path`;
630
+ 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`;
631
+ }
632
+
633
+ // Circuit breaker: too many consecutive no-result searches means the model
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.
639
+ if (consecutiveNoResults >= MAX_CONSECUTIVE_NO_RESULTS) {
640
+ if (debug) {
641
+ console.error(`[CIRCUIT-BREAKER] ${consecutiveNoResults} consecutive no-result searches, warning: "${searchQuery}"`);
642
+ }
643
+ const isSubfolderCB = path && path !== effectiveSearchCwd && path !== '.';
644
+ const cbScopeHint = isSubfolderCB
645
+ ? ` You have been searching in "${path}" — consider searching from the workspace root or a different directory.`
646
+ : '';
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.`;
648
+ }
481
649
  } else {
482
650
  // Cap pagination to prevent runaway page-through of broad queries
483
651
  const pageCount = (paginationCounts.get(searchKey) || 0) + 1;
@@ -493,11 +661,28 @@ export const searchTool = (options = {}) => {
493
661
  const result = maybeAnnotate(await runRawSearch());
494
662
  // Track whether this search had results for better dedup messages
495
663
  if (typeof result === 'string' && result.includes('No results found')) {
664
+ // Track consecutive no-results and failed concepts for circuit breaker
665
+ consecutiveNoResults++;
666
+ const normalizedKey = `${searchPath}::${normalizeQueryConcept(searchQuery)}`;
667
+ failedConcepts.set(normalizedKey, (failedConcepts.get(normalizedKey) || 0) + 1);
668
+ if (debug) {
669
+ console.error(`[NO-RESULTS] consecutiveNoResults=${consecutiveNoResults}, concept "${normalizeQueryConcept(searchQuery)}" failed ${failedConcepts.get(normalizedKey)}x`);
670
+ }
496
671
  // Append contextual hint for ticket/issue ID queries
497
672
  if (/^[A-Z]+-\d+$/.test(searchQuery.trim()) || /^[A-Z]+-\d+$/.test(searchQuery.replace(/"/g, '').trim())) {
498
- 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;
674
+ }
675
+ // Add a hint when approaching the circuit breaker threshold
676
+ if (consecutiveNoResults >= MAX_CONSECUTIVE_NO_RESULTS - 1 && !circuitBreakerWarning) {
677
+ const isSubfolderWarn = path && path !== effectiveSearchCwd && path !== '.';
678
+ const warnScopeHint = isSubfolderWarn
679
+ ? ` You are searching in "${path}" — consider searching from the workspace root or a different directory.`
680
+ : '';
681
+ 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.`;
499
682
  }
500
683
  } else if (typeof result === 'string') {
684
+ // Successful search — reset consecutive counter
685
+ consecutiveNoResults = 0;
501
686
  const entry = previousSearches.get(searchKey);
502
687
  if (entry) entry.hadResults = true;
503
688
  }
@@ -505,7 +690,7 @@ export const searchTool = (options = {}) => {
505
690
  if (options.fileTracker && typeof result === 'string') {
506
691
  options.fileTracker.trackFilesFromOutput(result, effectiveSearchCwd).catch(() => {});
507
692
  }
508
- return result;
693
+ return typeof result === 'string' ? result + circuitBreakerWarning : result;
509
694
  } catch (error) {
510
695
  console.error('Error executing search command:', error);
511
696
  const formatted = formatErrorForAI(error);
@@ -516,13 +701,76 @@ export const searchTool = (options = {}) => {
516
701
  }
517
702
  }
518
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
+
519
767
  try {
520
768
  if (debug) {
521
- console.error(`Delegating search with query: "${searchQuery}", path: "${searchPath}"`);
769
+ console.error(`Delegating search with query: "${effectiveQuery}", path: "${searchPath}"${effectiveQuery !== searchQuery ? ` (rewritten from: "${searchQuery}")` : ''}`);
522
770
  }
523
771
 
524
772
  const delegateTask = buildSearchDelegateTask({
525
- searchQuery,
773
+ searchQuery: effectiveQuery,
526
774
  searchPath,
527
775
  exact,
528
776
  language,
@@ -552,20 +800,37 @@ export const searchTool = (options = {}) => {
552
800
  const delegateResult = options.tracer?.withSpan
553
801
  ? await options.tracer.withSpan('search.delegate', runDelegation, {
554
802
  'search.query': searchQuery,
555
- 'search.path': searchPath
803
+ 'search.path': searchPath,
804
+ ...(effectiveQuery !== searchQuery ? { 'search.query.rewritten': effectiveQuery } : {})
556
805
  }, (span, result) => {
557
- 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}`);
558
808
  span.setAttributes({
559
809
  'search.delegate.output': truncateForSpan(text),
560
- 'search.delegate.output_length': text.length
810
+ 'search.delegate.output_length': String(text.length)
561
811
  });
562
812
  })
563
813
  : await runDelegation();
564
814
 
565
- const targets = parseDelegatedTargets(delegateResult);
566
- 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
+ }
567
832
  if (debug) {
568
- 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');
569
834
  }
570
835
  const fallbackResult = maybeAnnotate(await runRawSearch());
571
836
  if (options.fileTracker && typeof fallbackResult === 'string') {
@@ -574,78 +839,52 @@ export const searchTool = (options = {}) => {
574
839
  return fallbackResult;
575
840
  }
576
841
 
577
- // The delegate runs from workspace root (allowedFolders[0] or cwd), NOT from searchPaths[0].
578
- // It returns paths relative to that workspace root. Resolve against the same base.
842
+ // Resolve and validate file paths in each group
579
843
  const delegateBase = options.allowedFolders?.[0] || options.cwd || '.';
580
844
  const resolutionBase = searchPaths[0] || options.cwd || '.';
581
- const resolvedTargets = targets.map(target => resolveTargetPath(target, delegateBase));
845
+ const wsPrefix = resolutionBase.endsWith('/') ? resolutionBase : resolutionBase + '/';
582
846
 
583
- // Auto-fix: detect and repair invalid paths (doubled segments, AI hallucinations)
584
- const validatedTargets = [];
585
- for (const target of resolvedTargets) {
586
- 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);
587
852
 
588
- // 1. Path exists as-is
589
- if (existsSync(filePart)) {
590
- validatedTargets.push(target);
591
- continue;
592
- }
853
+ // 1. Path exists as-is
854
+ if (existsSync(filePart)) return target;
593
855
 
594
- // 2. Detect doubled directory segments: /ws/proj/proj/src → /ws/proj/src
595
- let fixed = false;
596
- const parts = filePart.split('/').filter(Boolean);
597
- for (let i = 0; i < parts.length - 1; i++) {
598
- if (parts[i] === parts[i + 1]) {
599
- const candidate = '/' + [...parts.slice(0, i), ...parts.slice(i + 1)].join('/');
600
- if (existsSync(candidate)) {
601
- validatedTargets.push(candidate + suffix);
602
- if (debug) console.error(`[search-delegate] Fixed doubled path segment: ${filePart} → ${candidate}`);
603
- fixed = true;
604
- 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
+ }
605
866
  }
606
- }
607
- }
608
- if (fixed) continue;
609
-
610
- // 3. Try resolving against alternative bases (searchPaths[0], cwd)
611
- for (const altBase of [resolutionBase, options.cwd].filter(Boolean)) {
612
- if (altBase === delegateBase) continue;
613
- const altResolved = resolveTargetPath(target, altBase);
614
- const { filePart: altFile } = splitTargetSuffix(altResolved);
615
- if (existsSync(altFile)) {
616
- validatedTargets.push(altResolved);
617
- if (debug) console.error(`[search-delegate] Resolved with alt base: ${filePart} → ${altFile}`);
618
- fixed = true;
619
- break;
620
- }
621
- }
622
- if (fixed) continue;
623
-
624
- // 4. Keep target anyway (probe binary will report the error)
625
- // but log a warning
626
- if (debug) console.error(`[search-delegate] Warning: target may not exist: ${filePart}`);
627
- validatedTargets.push(target);
628
- }
629
-
630
- const extractOptions = {
631
- files: validatedTargets,
632
- cwd: resolutionBase,
633
- allowTests: allow_tests ?? true
634
- };
635
867
 
636
- if (outline) {
637
- extractOptions.format = 'xml';
638
- }
639
-
640
- 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
+ }
641
878
 
642
- // Strip workspace root prefix from extract output so paths are relative
643
- if (resolutionBase && typeof extractResult === 'string') {
644
- const wsPrefix = resolutionBase.endsWith('/') ? resolutionBase : resolutionBase + '/';
645
- 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(''));
646
884
  }
647
885
 
648
- return maybeAnnotate(extractResult);
886
+ // Return structured JSON for the parent AI to decide what to extract
887
+ return JSON.stringify(structured, null, 2);
649
888
  } catch (error) {
650
889
  console.error('Delegated search failed, falling back to raw search:', error);
651
890
  try {