@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.
- package/bin/binaries/{probe-v0.6.0-rc302-aarch64-apple-darwin.tar.gz → probe-v0.6.0-rc304-aarch64-apple-darwin.tar.gz} +0 -0
- package/bin/binaries/{probe-v0.6.0-rc302-aarch64-unknown-linux-musl.tar.gz → probe-v0.6.0-rc304-aarch64-unknown-linux-musl.tar.gz} +0 -0
- package/bin/binaries/{probe-v0.6.0-rc302-x86_64-apple-darwin.tar.gz → probe-v0.6.0-rc304-x86_64-apple-darwin.tar.gz} +0 -0
- package/bin/binaries/{probe-v0.6.0-rc302-x86_64-pc-windows-msvc.zip → probe-v0.6.0-rc304-x86_64-pc-windows-msvc.zip} +0 -0
- package/bin/binaries/{probe-v0.6.0-rc302-x86_64-unknown-linux-musl.tar.gz → probe-v0.6.0-rc304-x86_64-unknown-linux-musl.tar.gz} +0 -0
- package/build/agent/FallbackManager.js +3 -57
- package/build/agent/ProbeAgent.js +48 -62
- package/build/delegate.js +15 -4
- package/build/tools/common.js +16 -1
- package/build/tools/vercel.js +448 -209
- package/build/utils/provider.js +106 -0
- package/cjs/agent/ProbeAgent.cjs +1078 -305
- package/cjs/index.cjs +529 -303
- package/package.json +1 -1
- package/src/agent/FallbackManager.js +3 -57
- package/src/agent/ProbeAgent.js +48 -62
- package/src/delegate.js +15 -4
- package/src/tools/common.js +16 -1
- package/src/tools/vercel.js +448 -209
- package/src/utils/provider.js +106 -0
package/build/tools/vercel.js
CHANGED
|
@@ -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
|
-
|
|
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: {
|
|
93
|
-
|
|
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: ['
|
|
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
|
-
|
|
190
|
-
|
|
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
|
-
|
|
211
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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: "${
|
|
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
|
|
566
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
|
845
|
+
const wsPrefix = resolutionBase.endsWith('/') ? resolutionBase : resolutionBase + '/';
|
|
582
846
|
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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
|
-
|
|
589
|
-
|
|
590
|
-
validatedTargets.push(target);
|
|
591
|
-
continue;
|
|
592
|
-
}
|
|
853
|
+
// 1. Path exists as-is
|
|
854
|
+
if (existsSync(filePart)) return target;
|
|
593
855
|
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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
|
-
|
|
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 {
|