@feng0531/dsh-code-review 0.85.11 → 0.86.0

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/src/review.mjs CHANGED
@@ -29,6 +29,8 @@ import { changesConcurrency, retrievalRounds } from './review-execution-policy.m
29
29
  import { scheduleFileChains } from './file-chain-scheduler.mjs';
30
30
  import { reviewReaderBudget, readerAdmission } from './review-reader-policy.mjs';
31
31
  import { primarySeeds } from './primary-seeds.mjs';
32
+ import { prepareGroupPrimary, validateGroupPrimary } from './group-primary.mjs';
33
+ import { configureWireBudget, modelWireBudget } from './model-wire-budget.mjs';
32
34
 
33
35
  export { REVIEW_INSTRUCTIONS };
34
36
  export { PROJECT_REVIEW_INSTRUCTIONS };
@@ -99,6 +101,8 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
99
101
  const maxRetrievalRounds = retrievalRounds(options.maxRetrievalRounds);
100
102
  let checkpointFailed = false, checkpointFailure;
101
103
  const progress = options.onProgress;
104
+ const resumeCheckpoint = options.onResumeCheckpoint;
105
+ if (resumeCheckpoint) options = { ...options, onResumeCheckpoint: async report => { try { await resumeCheckpoint(report); } catch (error) { checkpointFailed = true; checkpointFailure = error; controller.abort(error); throw error; } } };
102
106
  options = { ...options, onProgress: progress ? async event => {
103
107
  if (checkpointFailed) throw checkpointFailure;
104
108
  try { await progress(structuredClone(event)); }
@@ -140,25 +144,85 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
140
144
  if(input.workingScope)report.workingScope=structuredClone(input.workingScope);
141
145
  if(input.effectiveMergeSources)report.effectiveMergeSources=structuredClone(input.effectiveMergeSources);
142
146
  report.rules = (input.rules ?? []).map(({ path, hash }) => ({ path, hash }));
147
+ if (scope) configureWireBudget(scope, { nativeOutputTools: options.nativeOutputTools === true });
143
148
  if (options.enableGrouping === true) report.grouping = buildReviewGroups(input.files, inferFileRelations(input.files));
144
149
  report.interactions = [];
145
150
  const interactionPlans = scope && report.grouping ? report.grouping.groups.filter(g => g.fileIds.length > 1).map(group => ({ group, prepared: prepareInteractionInput(input, group, payload => initialInputBudget({ instructions, input: payload }, scope, maxInputBytes)) })) : [];
146
151
  for (const plan of interactionPlans.filter(p => p.prepared.status !== 'not-applicable')) report.interactions.push({ groupId: plan.group.id, fileIds: plan.prepared.fileIds, status: plan.prepared.status === 'ready' ? 'pending' : 'blocked', reason: plan.prepared.reason, lifecycle: 'shares-last-primary-timeout' });
147
152
  const fileResults = new Array(input.files.length);
153
+ const sharedState = report.orchestration = { modelMetrics: {}, modelCallPurposes: {} };
154
+ const retainArtifact = options.onModelArtifact ? async artifact => { const reference = await options.onModelArtifact(artifact); if (reference) (report.artifacts ??= []).push(reference); } : undefined;
155
+ report.resume = { reusedFiles: options.resumeState?.completed?.length ?? 0 };
156
+ const reserveShared = ({ correction = false } = {}) => {
157
+ if (!correction && options.remainingCalls?.() <= 0) throw Object.assign(new Error('Shared project model call limit exceeded'), { code: 'PROJECT_MODEL_CALL_LIMIT' });
158
+ if (report.modelCalls >= maxCalls) throw new Error('Model call budget exceeded');
159
+ report.modelCalls++;
160
+ };
161
+ const sharedModel = async request => {
162
+ let correction;
163
+ return model({ ...request, nativeOutputTools: options.nativeOutputTools === true, maxInputBytes: options.nativeOutputTools === true ? maxInputBytes : request.maxInputBytes, onModelArtifact: retainArtifact ? artifact => retainArtifact({ ...artifact, snapshotId: input.id, phase: request.reviewPhase }) : undefined,
164
+ onProtocolEvent: async info => { if (info.action === 'json-transport-recovery') (sharedState.modelRecoveries ??= []).push(info); await request.onProtocolEvent?.(info); },
165
+ onProtocolCorrection: async info => { reserveShared({ correction: true }); correction = info; await request.onProtocolCorrection?.(info); },
166
+ onModelEvent: async info => {
167
+ if (info.action === 'model-end') {
168
+ const phase = request.reviewPhase, metrics = sharedState.modelMetrics[phase] ??= { calls: 0, durationMs: 0, inputBytes: 0, totalOutputBytes: 0 };
169
+ metrics.calls++; metrics.durationMs += info.durationMs; metrics.inputBytes += info.inputBytes; metrics.totalOutputBytes += info.totalOutputBytes;
170
+ if (info.callPurpose) sharedState.modelCallPurposes[info.callPurpose] = (sharedState.modelCallPurposes[info.callPurpose] ?? 0) + 1;
171
+ if (info.callPurpose === 'json-correction' && correction) { (sharedState.modelCorrections ??= []).push({ action: 'explicit-json-correction', code: 'MODEL_INVALID_JSON', jsonIssue: correction.jsonIssue }); correction = undefined; }
172
+ }
173
+ await options.onProgress?.({ stage: info.action, snapshotId: input.id, phase: request.reviewPhase, ...info });
174
+ } });
175
+ };
176
+ const groupPrepared = new Map(), groupResults = new Map(), groupStarted = new Map();
177
+ const groupResult = async group => {
178
+ if (!groupResults.has(group.id)) {
179
+ groupStarted.set(group.id, performance.now());
180
+ groupResults.set(group.id, invoke(async request => {
181
+ const prepared = groupPrepared.get(group.id); let limited = false; const reads = [];
182
+ const response = await retrievalLoop(sharedModel, { ...request, nativeOutputTools: options.nativeOutputTools === true, input: prepared.payload, instructions: prepared.instructions, reviewPhase: 'group-primary' }, scope, {
183
+ maxRounds: maxRetrievalRounds, maxInputBytes, beforeCall: reserveShared, correctProtocol: options.correctProtocol === true,
184
+ enableSearchSeeds: options.enableSearchSeeds === true,
185
+ onRetrieved: records => { reads.push(...records); (sharedState.retrievalAudit ??= []).push(...records); },
186
+ onBudgetLimited: info => { limited = true; (sharedState.retrievalLimits ??= []).push(info); },
187
+ });
188
+ const results = validateGroupPrimary(response, prepared, input, validateResponse, scope.catalog());
189
+ for (const generated of results.values()) {
190
+ generated.groupLimited = limited;
191
+ if (generated.groupRisk) {
192
+ generated.groupRisk.sourceReadCoverage = riskReadCoverage(generated.groupRisk.risks, reads, scope.catalog(), input.id);
193
+ if (generated.groupRisk.status !== 'completed') generated.limitations.push('组主审的合并风险计划缺失或无效,未补发规划调用;不表示已完成风险条件核查');
194
+ if (generated.groupRisk.sourceReadCoverage.some(item => item.unreadSourceIds.length)) generated.limitations.push('组风险计划请求的部分来源未在本次组生成读取;不表示已核查或排除风险条件');
195
+ }
196
+ }
197
+ return results;
198
+ }, { input: groupPrepared.get(group.id).payload, instructions: groupPrepared.get(group.id).instructions }, timeoutMs, signal));
199
+ }
200
+ return groupResults.get(group.id);
201
+ };
148
202
  const runFile = async index => {
149
203
  const file = input.files[index];
150
204
  const state = { fileId: file.id, path: file.path, status: file.eligibility === 'reviewable' ? 'pending' : file.eligibility, reason: file.reason, ...(file.blockCode ? {blockCode:file.blockCode} : {}) };
151
- const result = { state, findings: [], limitations: [] };
205
+ let result = { state, findings: [], limitations: [] };
152
206
  fileResults[index] = result;
153
207
  try {
154
208
  await options.onProgress?.({stage:'file-started',snapshotId:input.id,fileId:file.id,path:file.path,status:state.status});
155
209
  if (state.status !== 'pending') return;
210
+ const reusable = options.resumeState?.completed?.find(row => row.fileId === file.id && row.path === file.path);
211
+ if (reusable) {
212
+ const prior = structuredClone(reusable.state);
213
+ for (const key of ['modelMetrics', 'modelCallPurposes', 'modelCorrections', 'modelRecoveries', 'retrievalAudit', 'protocolAttempts', 'protocolDiagnostic', 'responseAttempts', 'responseCorrections', 'anchorCorrections', 'retrievalLimits']) delete prior[key];
214
+ Object.assign(state, prior, { reused: true, reuseNotice: 'Prior completed evidence retained for an identical approved snapshot/configuration; no new model or evidence receipt claimed.' });
215
+ result.findings = structuredClone(reusable.findings).map(finding => ({ ...finding, reused: true, priorRun: { snapshotId: options.resumeState.snapshotId, evidenceStatus: 'historical-verified-evidence-not-current-run-receipts' } }));
216
+ result.limitations = structuredClone(reusable.limitations ?? []);
217
+ return;
218
+ }
156
219
  if (signal?.aborted) { state.status = 'cancelled'; state.reason = '用户取消'; return; }
157
- if (report.modelCalls >= maxCalls || options.remainingCalls?.() <= 0) { state.reason = '模型调用预算耗尽'; return; }
158
- const prepared = preparePrimaryInput(input, file, { instructions, scope, grouping: report.grouping, maxInputBytes, riskPlanningMode: options.riskPlanningMode, enableRiskPlanning: options.enableRiskPlanning, skipSmallRiskPlans: options.skipSmallRiskPlans });
220
+ const assignedGroup = [...groupPrepared].find(([, value]) => value.fileIds.includes(file.id));
221
+ if (!assignedGroup && (report.modelCalls >= maxCalls || options.remainingCalls?.() <= 0)) { state.reason = '模型调用预算耗尽'; return; }
222
+ const prepared = preparePrimaryInput(input, file, { instructions, scope, grouping: report.grouping, maxInputBytes, riskPlanningMode: options.riskPlanningMode, focusPrimaryContext: options.focusPrimaryContext === true, enableRiskPlanning: options.enableRiskPlanning, skipSmallRiskPlans: options.skipSmallRiskPlans });
159
223
  const { changes, payload } = prepared;
160
224
  Object.assign(state, prepared.metadata);
161
- if (state.contextMode === 'approved-retrieval') result.limitations.push({ fileId: file.id, text: '窗口初始输入仅带上下文身份目录,全文须按需读取;不声明所有上下文已核查。' });
225
+ if (state.contextMode === 'approved-retrieval') result.limitations.push({ fileId: file.id, text: '初始输入仅提供聚焦上下文或身份目录,其他批准全文须按需读取;不声明所有上下文已核查。' });
162
226
  if (prepared.synthesis) state.synthesisStatus = prepared.synthesis.status === 'ready' ? 'pending' : 'blocked';
163
227
  if (prepared.synthesis?.status === 'blocked') result.limitations.push({ fileId: file.id, text: (prepared.synthesis.reason === 'batch-limit' ? '跨窗口综合批次数超过上限(20),未发送;全部主窗口仍独立审查' : '跨窗口综合输入超限,未发送') + ';单窗口完成不表示交互影响已检查' });
164
228
  if (state.sourceMode === 'change-windows') state.windowCoverage = { completed: [], pending: [...state.windowIds] };
@@ -177,11 +241,26 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
177
241
  (state.retrievalLimits ??= []).push(info);
178
242
  result.limitations.push({ fileId: file.id, text: '检索预算受限,已保留现有结果但不能视为完整审查:' + info.reason });
179
243
  };
180
- const onProtocolEvent = async info => { await options.onProgress?.({ stage: 'protocol-correction', fileId: file.id, path: file.path, ...info }); };
181
- const trackedModel = request => model({ ...request, callPurpose: request.callPurpose ?? request.reviewPhase ?? 'primary-initial', onProtocolEvent,
244
+ let pendingStageCorrection;
245
+ const onProtocolEvent = async info => {
246
+ if (info.action === 'json-transport-recovery') (state.modelRecoveries ??= []).push(info);
247
+ if (info.action === 'retrieval-protocol-correction') pendingStageCorrection = { action: info.action, code: 'RETRIEVAL_PROTOCOL_ERROR' };
248
+ await options.onProgress?.({ stage: 'protocol-correction', fileId: file.id, path: file.path, ...info });
249
+ };
250
+ const trackedModel = request => {
251
+ request.signal?.throwIfAborted();
252
+ // The executor has admitted this call; budget-vetoed retries never reach here.
253
+ if (pendingStageCorrection) { (state.modelCorrections ??= []).push(pendingStageCorrection); pendingStageCorrection = undefined; }
254
+ let pendingJsonCorrection;
255
+ return model({ ...request, nativeOutputTools: options.nativeOutputTools === true, maxInputBytes: options.nativeOutputTools === true ? maxInputBytes : request.maxInputBytes, callPurpose: request.callPurpose ?? request.reviewPhase ?? 'primary-initial', onProtocolEvent,
256
+ onModelArtifact: retainArtifact ? artifact => retainArtifact({ ...artifact, snapshotId: input.id, fileId: file.id, path: file.path, phase: request.reviewPhase ?? 'primary' }) : undefined,
182
257
  onModelEvent: async info => {
183
258
  if (info.action === 'model-end') {
184
259
  const phase = request.reviewPhase ?? 'primary';
260
+ if (info.callPurpose === 'json-correction' && pendingJsonCorrection) {
261
+ (state.modelCorrections ??= []).push({ action: 'explicit-json-correction', code: 'MODEL_INVALID_JSON', ...(pendingJsonCorrection.jsonIssue ? { jsonIssue: pendingJsonCorrection.jsonIssue } : {}), phase });
262
+ pendingJsonCorrection = undefined;
263
+ }
185
264
  const metrics = (state.modelMetrics ??= {});
186
265
  const item = (metrics[phase] ??= { calls: 0, durationMs: 0, inputBytes: 0, totalOutputBytes: 0 });
187
266
  item.calls++; item.durationMs += info.durationMs; item.inputBytes += info.inputBytes; item.totalOutputBytes += info.totalOutputBytes;
@@ -189,43 +268,32 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
189
268
  }
190
269
  await options.onProgress?.({ stage: info.action, snapshotId: input.id, fileId: file.id, path: file.path, phase: request.reviewPhase ?? 'primary', ...info });
191
270
  },
192
- onProtocolCorrection: async info => { beforeCall({ reserved: true }); await request.onProtocolCorrection?.(info); await options.onProgress?.({stage:'protocol-correction',fileId:file.id,path:file.path,...info}); },
271
+ onProtocolCorrection: async info => {
272
+ beforeCall({ reserved: true });
273
+ await request.onProtocolCorrection?.(info);
274
+ pendingJsonCorrection = { jsonIssue: info.jsonIssue };
275
+ await options.onProgress?.({stage:'protocol-correction',fileId:file.id,path:file.path,...info});
276
+ },
193
277
  });
278
+ };
194
279
  const executor = scope
195
- ? (request, onLimited = () => {}) => retrievalLoop(trackedModel, { ...request, onProtocolEvent }, scope, { correctProtocol:options.correctProtocol===true,maxRounds:maxRetrievalRounds,maxInputBytes, beforeCall, initialRetrieved: request.initialRetrieved ?? [], allowExhaustedInitial: request.allowExhaustedInitial === true, onBudgetLimited: info => { onBudgetLimited(info); onLimited(info); }, onRetrieved: records => {
280
+ ? (request, onLimited = () => {}) => retrievalLoop(trackedModel, { ...request, nativeOutputTools: options.nativeOutputTools === true, onProtocolEvent }, scope, { correctProtocol:options.correctProtocol===true,maxRounds:maxRetrievalRounds,maxInputBytes, beforeCall, initialRetrieved: request.initialRetrieved ?? [], allowExhaustedInitial: request.allowExhaustedInitial === true, enableSearchSeeds: options.enableSearchSeeds === true && !request.reviewPhase?.includes('verification'), onBudgetLimited: info => { onBudgetLimited(info); onLimited(info); }, onRetrieved: records => {
196
281
  (state.retrievalAudit ??= []).push(...records);
197
282
  }, onTruncated: () => {
198
283
  if (!state.retrievalTruncated) result.limitations.push({ fileId: file.id, text: '检索结果达到数量上限,部分匹配未提供给模型' });
199
284
  state.retrievalTruncated = true;
200
285
  } })
201
286
  : request => { beforeCall(); return trackedModel(request); };
287
+ const grouped = assignedGroup;
288
+ const groupedGeneration = grouped ? (await groupResult({ id: grouped[0] })).get(file.id) : null;
289
+ if (grouped) {
290
+ state.primaryGroupId = grouped[0]; state.primaryMethod = 'group-primary';
291
+ if (groupedGeneration.groupLimited) state.retrievalLimited = true;
292
+ if (groupedGeneration.groupRisk) (state.riskPlans ??= []).push({ ...groupedGeneration.groupRisk, method: 'inline', scope: 'group-all-edits', groupId: grouped[0], decision: { required: true, reason: 'group-inline-primary' } });
293
+ }
294
+ const chainTimeout = grouped && timeoutMs > 0 ? Math.max(0, timeoutMs - (performance.now() - groupStarted.get(grouped[0]))) : timeoutMs;
295
+ if (grouped && timeoutMs > 0 && chainTimeout <= 0) throw Object.assign(new Error('Model timeout'), { code: 'MODEL_TIMEOUT' });
202
296
  await invoke(async enclosingRequest => {
203
- if (options.enableBusinessGrouping === true && !report.businessGrouping && prepareBusinessGroups(input.files)) {
204
- report.businessGrouping = { status: 'pending' };
205
- try {
206
- const local = localBusinessGroups(input.files);
207
- let grouped = local;
208
- if (!local) {
209
- const groupInput = prepareBusinessGroups(input.files);
210
- if (Buffer.byteLength(groupInput) + Buffer.byteLength(BUSINESS_GROUP_INSTRUCTIONS) > maxInputBytes) throw new Error('Business grouping input budget exceeded');
211
- beforeCall();
212
- const response = await trackedModel({ ...enclosingRequest, instructions: BUSINESS_GROUP_INSTRUCTIONS, input: groupInput, reviewPhase: 'business-grouping' });
213
- enclosingRequest.signal.throwIfAborted();
214
- grouped = validateBusinessGroups(response, input.files);
215
- }
216
- report.businessGrouping = { status: 'completed', ...grouped };
217
- for (const group of grouped.groups.filter(g => g.fileIds.length > 1)) {
218
- if (interactionPlans.some(p => [...p.group.fileIds].sort().join(',') === [...group.fileIds].sort().join(','))) continue;
219
- const prepared = prepareInteractionInput(input, group, payload => initialInputBudget({ instructions, input: payload }, scope, maxInputBytes));
220
- interactionPlans.push({ group, prepared });
221
- if (prepared.status !== 'not-applicable') report.interactions.push({ groupId: group.id, fileIds: prepared.fileIds, status: prepared.status === 'ready' ? 'pending' : 'blocked', reason: prepared.reason, lifecycle: 'shares-last-primary-timeout' });
222
- }
223
- } catch (error) {
224
- report.businessGrouping = { status: 'fallback', reason: error.message };
225
- result.limitations.push({ text: '业务分组未完成,保留原分组:' + error.message });
226
- enclosingRequest.signal.throwIfAborted();
227
- }
228
- }
229
297
  const batches = [...(prepared.batches ?? [{ payload, windowIds: state.windowIds ?? [] }])];
230
298
  if (prepared.synthesis?.status === 'ready') batches.push({ payload: prepared.synthesis.payload, windowIds: [], synthesis: true });
231
299
  const matchingInteractionPlans = interactionPlans.filter(p => input.files.filter(f => p.group.fileIds.includes(f.id)).at(-1)?.id === file.id);
@@ -248,14 +316,14 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
248
316
  continue;
249
317
  }
250
318
  let batchPayload = batch.payload;
251
- const inlineRisk = prepared.inlineRisk === true && batch.round === 1 && !batch.synthesis && !batch.interaction;
319
+ const inlineRisk = !groupedGeneration && prepared.inlineRisk === true && batch.round === 1 && !batch.synthesis && !batch.interaction;
252
320
  const batchInstructions = inlineRisk ? prepared.instructions : instructions;
253
321
  let riskEntry, activeRisks = [];
254
322
  if (inlineRisk) {
255
- riskEntry = { status: 'pending', method: 'inline', windowIds: [], scope: 'full-file', risks: [], decision: { required: true, reason: 'inline-primary' } };
323
+ riskEntry = { status: 'pending', method: 'inline', windowIds: batch.windowIds, scope: state.sourceMode === 'change-windows' ? 'all-edits' : 'full-file', risks: [], decision: { required: true, reason: 'inline-primary' } };
256
324
  (state.riskPlans ??= []).push(riskEntry);
257
325
  }
258
- if (options.enableRiskPlanning === true && !inlineRisk && !batch.synthesis && batch.round === 1) {
326
+ if (options.enableRiskPlanning === true && (!groupedGeneration || batch.interaction) && !inlineRisk && !batch.synthesis && batch.round === 1) {
259
327
  const reusable = !batch.interaction && riskInput.cacheable;
260
328
  riskEntry = reusable ? primaryRiskPlan : undefined;
261
329
  if (!riskEntry) {
@@ -296,9 +364,9 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
296
364
  }
297
365
  }
298
366
  }
299
- if(report.modelCalls>=maxCalls)throw new Error('Model call budget exceeded before next review pass');
367
+ if(report.modelCalls>=maxCalls && !(groupedGeneration && !batch.synthesis && !batch.interaction))throw new Error('Model call budget exceeded before next review pass');
300
368
  if(rounds>1) state.reviewPasses[batchIndex].status='running';
301
- const request = { ...enclosingRequest, instructions: batchInstructions, input: batchPayload, reviewPhase: phase, allowExhaustedInitial: options.optimizeReviewCalls === true && !batch.synthesis && !batch.interaction && prepared.metadata.contextMode !== 'approved-retrieval' };
369
+ const request = { ...enclosingRequest, nativeOutputTools: options.nativeOutputTools === true, instructions: batchInstructions, input: batchPayload, reviewPhase: phase, allowExhaustedInitial: options.optimizeReviewCalls === true && !batch.synthesis && !batch.interaction && prepared.metadata.contextMode !== 'approved-retrieval' };
302
370
  let auditStart = state.retrievalAudit?.length ?? 0;
303
371
  let finalResponseOnly = false;
304
372
  const generate = async attempt => {
@@ -328,13 +396,14 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
328
396
  if (!batch.interaction && state.sourceMode === 'change-windows') validateWindowFindings(generated.findings, batch.synthesis ? prepared.synthesis.windows : JSON.parse(batch.payload).windows, batch.synthesis === true);
329
397
  return generated;
330
398
  };
331
- const generated = await correctedResponse(generate, validate, request, {
399
+ const generated = groupedGeneration && !batch.synthesis && !batch.interaction ? structuredClone(groupedGeneration) : await correctedResponse(generate, validate, request, {
332
400
  enabled: options.correctProtocol === true,
333
401
  canCorrect: () => !finalResponseOnly,
334
402
  onCorrection: async info => {
335
403
  // A fresh generation must establish its own synthesis/interaction read evidence.
336
404
  auditStart = state.retrievalAudit?.length ?? 0;
337
405
  (state.responseCorrections ??= []).push({ code: info.code, round: batch.round, phase });
406
+ pendingStageCorrection = { action: 'review-format-correction', code: info.code, phase };
338
407
  result.limitations.push({ fileId: file.id, text: '主审响应校验失败,已请求一次显式纠正;原响应未接受,纠正不证明缺陷成立。' });
339
408
  await options.onProgress?.({ stage: 'protocol-correction', fileId: file.id, path: file.path, ...info });
340
409
  },
@@ -348,7 +417,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
348
417
  }
349
418
  delete generated.inlineRisks;
350
419
  }
351
- if (options.enableRiskPlanning === true && !batch.synthesis && batch.round === 1) {
420
+ if (options.enableRiskPlanning === true && (!groupedGeneration || batch.interaction) && !batch.synthesis && batch.round === 1) {
352
421
  const coverage = riskReadCoverage(activeRisks,(state.retrievalAudit??[]).slice(auditStart),scope?.catalog()??[],input.id);
353
422
  (riskEntry.generations ??= []).push({ round: batch.round, windowIds: batch.windowIds, coverage });
354
423
  // Preserve the single-generation field without unioning evidence across windows.
@@ -374,6 +443,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
374
443
  if (verdicts.some(v => v.verdict === 'supported' && v.regression?.classification === 'uncertain')) result.limitations.push({ fileId: file.id, text: '部分模型支持候选缺少充分前后对照,不能确认由本次变更引入' });
375
444
  if (verdicts.some(v => v.verdict === 'uncertain')) result.limitations.push({ fileId: file.id, text: '部分候选复核仍不确定,不能视为已确认或已排除' });
376
445
  } catch (error) {
446
+ if (error?.code === 'MODEL_ARTIFACT_FAILED') throw error;
377
447
  for (const finding of generated.findings) finding.verification = { status: 'incomplete', causality: 'unverified' };
378
448
  result.limitations.push({ fileId: file.id, text: '候选复核未完成:' + (error?.message ?? String(error)) });
379
449
  request.signal.throwIfAborted();
@@ -393,7 +463,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
393
463
  await options.onProgress?.({stage:'window-complete',snapshotId:input.id,fileId:file.id,path:file.path,windowIds:batch.windowIds,round:batch.round,findings:generated.findings,windowCoverage:state.windowCoverage});
394
464
  }catch(error){
395
465
  enclosingRequest.signal.throwIfAborted();
396
- if(phase==='primary'||/budget exceeded/i.test(error.message))throw error;
466
+ if(phase==='primary'||error.code==='MODEL_ARTIFACT_FAILED'||(/budget exceeded/i.test(error.message)&&!groupedGeneration))throw error;
397
467
  const failure={phase,status:'failed',code:error.code??'REVIEW_EXECUTION_FAILED',reason:error.message,round:batch.round,...(error.attempts?{attempts:error.attempts}:{}),...(error.diagnostic?{diagnostic:error.diagnostic}:{})};
398
468
  (state.stageFailures??=[]).push(failure);
399
469
  if(batch.synthesis)state.synthesisStatus='failed';
@@ -403,13 +473,20 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
403
473
  await options.onProgress?.({stage:'synthesis-failed',snapshotId:input.id,fileId:file.id,path:file.path,...failure,windowCoverage:state.windowCoverage});
404
474
  }
405
475
  }
406
- }, { instructions, input: payload }, timeoutMs, signal);
476
+ }, { instructions, input: payload }, chainTimeout, signal);
407
477
  if (signal?.aborted) throw signal.reason;
408
478
 
409
479
  state.status = state.retrievalLimited ? 'pending' : 'completed';
410
- if (state.retrievalLimited) state.reason = '检索预算受限,未完成完整审查;已有发现保留,不表示通过';
480
+ if (state.retrievalLimited) {
481
+ const codes = new Set((state.retrievalLimits ?? []).map(limit => limit.code));
482
+ state.reason = codes.has('RETRIEVAL_ROUND_BUDGET_EXCEEDED')
483
+ ? '单阶段检索轮数上限达到,未完成完整审查;共享读取额度可能仍有余量,已有发现保留,不表示通过'
484
+ : '共享读取调用或输出预算受限,未完成完整审查;已有发现保留,不表示通过';
485
+ if (codes.size === 1) state.failureCode = [...codes][0];
486
+ }
411
487
 
412
488
  } catch (error) {
489
+ if (error?.code === 'MODEL_ARTIFACT_FAILED') { checkpointFailed = true; checkpointFailure = error; controller.abort(error); throw error; }
413
490
  state.status = signal?.aborted ? 'cancelled' : error?.code === 'PROJECT_MODEL_CALL_LIMIT' ? 'pending' : 'failed';
414
491
  state.reason = error?.message ?? String(error);
415
492
  if(error?.attempts)state.protocolAttempts=error.attempts;
@@ -419,13 +496,49 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
419
496
  state.failureCode = signal?.aborted ? 'CANCELLED' : ['MODEL_INVALID_JSON','MODEL_INCOMPLETE_OUTPUT','MODEL_TIMEOUT','MODEL_INVALID_READ_REQUEST','MODEL_OUTPUT_LIMIT','MODEL_PROTOCOL_ERROR','MODEL_INVALID_REVIEW_RESPONSE','MODEL_INVALID_FINDING_IDENTITY','MODEL_INVALID_FINDING_SEVERITY','MODEL_MISSING_FINDING_FIELD','MODEL_INVALID_ANCHOR','MODEL_ANCHOR_MISMATCH','READER_CALL_BUDGET_EXCEEDED','READER_OUTPUT_BUDGET_EXCEEDED','RETRIEVAL_ROUND_BUDGET_EXCEEDED','RETRIEVAL_FINAL_RESPONSE_REQUIRED','PROJECT_MODEL_CALL_LIMIT'].includes(error?.code) ? error.code : 'REVIEW_EXECUTION_FAILED';
420
497
  }
421
498
  } finally {
499
+ state.resumeEligible = state.status === 'completed' && !state.stageFailures?.length && !state.reviewPasses?.some(pass => pass.status !== 'completed') && !state.windowCoverage?.pending?.length && !state.retrievalLimited && result.findings.every(f => f.verification?.status === 'completed') && report.interactions.filter(item => item.fileIds?.includes(file.id)).every(item => item.status === 'completed');
422
500
  await options.onProgress?.({stage:'file-complete',snapshotId:input.id,file:state,findings:result.findings});
501
+ await options.onResumeCheckpoint?.({ ...report, files: fileResults.filter(Boolean).map(item => item.state), findings: fileResults.filter(Boolean).flatMap(item => item.findings), limitations: [...report.limitations, ...fileResults.filter(Boolean).flatMap(item => item.limitations)] });
423
502
  }
424
503
  };
425
- // Bootstrap lazy business grouping once before freezing dependency admission.
426
- let first = 0;
427
- if (options.enableBusinessGrouping === true && prepareBusinessGroups(input.files)) {
428
- while (first < input.files.length && !report.businessGrouping && !signal.aborted) await runFile(first++);
504
+ if (options.resumeState?.businessGrouping) report.businessGrouping = structuredClone(options.resumeState.businessGrouping);
505
+ for (const interaction of report.interactions) {
506
+ if (interaction.fileIds.every(id => options.resumeState?.completed?.some(row => row.fileId === id))) Object.assign(interaction, { status: 'completed', reused: true });
507
+ }
508
+ // Freeze grouping before admitting file chains, without serializing a whole first file.
509
+ if (options.enableBusinessGrouping === true && prepareBusinessGroups(input.files) && !signal.aborted) {
510
+ try {
511
+ const local = report.businessGrouping?.groups ? report.businessGrouping : localBusinessGroups(input.files);
512
+ const grouped = local ?? await invoke(async request => {
513
+ const groupInput = prepareBusinessGroups(input.files);
514
+ if ((options.nativeOutputTools === true ? !modelWireBudget({ input: groupInput, instructions: BUSINESS_GROUP_INSTRUCTIONS, nativeOutputTools: true }, maxInputBytes).fits : Buffer.byteLength(groupInput) + Buffer.byteLength(BUSINESS_GROUP_INSTRUCTIONS) > maxInputBytes)) throw new Error('Business grouping input budget exceeded');
515
+ reserveShared();
516
+ return validateBusinessGroups(await sharedModel({ ...request, reviewPhase: 'business-grouping', callPurpose: 'business-grouping' }), input.files);
517
+ }, { input: prepareBusinessGroups(input.files), instructions: BUSINESS_GROUP_INSTRUCTIONS }, timeoutMs, signal);
518
+ report.businessGrouping = { status: 'completed', ...grouped };
519
+ for (const group of grouped.groups.filter(g => g.fileIds.length > 1)) {
520
+ if (interactionPlans.some(p => [...p.group.fileIds].sort().join(',') === [...group.fileIds].sort().join(','))) continue;
521
+ const prepared = prepareInteractionInput(input, group, payload => initialInputBudget({ instructions, input: payload }, scope, maxInputBytes));
522
+ interactionPlans.push({ group, prepared });
523
+ if (prepared.status !== 'not-applicable') report.interactions.push({ groupId: group.id, fileIds: prepared.fileIds, status: group.fileIds.every(id => options.resumeState?.completed?.some(row => row.fileId === id)) ? 'completed' : prepared.status === 'ready' ? 'pending' : 'blocked', reason: prepared.reason, lifecycle: 'shares-last-primary-timeout' });
524
+ }
525
+ } catch (error) {
526
+ if (signal.aborted || checkpointFailed || error.code === 'MODEL_ARTIFACT_FAILED') throw error;
527
+ report.businessGrouping = { status: 'fallback', reason: error.message };
528
+ report.limitations.push({ text: '业务分组未完成,保留原分组:' + error.message });
529
+ }
530
+ }
531
+ if (options.enableGroupPrimary === true && scope && rounds === 1) {
532
+ const seen = new Set();
533
+ const groups = report.businessGrouping?.groups ?? report.grouping?.groups ?? [];
534
+ for (const group of groups) {
535
+ if (group.fileIds.some(id => seen.has(id) || options.resumeState?.completed?.some(row => row.fileId === id))) continue;
536
+ const prepared = prepareGroupPrimary(input, group, { instructions, scope, maxInputBytes, focusPrimaryContext: options.focusPrimaryContext === true, enableRiskPlanning: options.enableRiskPlanning === true });
537
+ if (!prepared) continue;
538
+ groupPrepared.set(group.id, prepared);
539
+ for (const id of group.fileIds) seen.add(id);
540
+ }
541
+ report.primaryGroups = [...groupPrepared].map(([groupId, p]) => ({ groupId, fileIds: p.fileIds, mode: 'group-primary' }));
429
542
  }
430
543
  const dependencies = new Map();
431
544
  for (const plan of interactionPlans.filter(p => p.prepared.status === 'ready')) {
@@ -433,7 +546,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
433
546
  const owner = members.at(-1);
434
547
  if (owner !== undefined) dependencies.set(owner, [...new Set([...(dependencies.get(owner) ?? []), ...members.slice(0, -1)])]);
435
548
  }
436
- await scheduleFileChains(input.files.map((_, index) => index).slice(first), async index => {
549
+ await scheduleFileChains(input.files.map((_, index) => index), async index => {
437
550
  try { await runFile(index); }
438
551
  catch (error) { controller.abort(error); throw error; }
439
552
  }, dependencies, fileConcurrency);
@@ -442,6 +555,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
442
555
  report.findings = fileResults.flatMap(result => result.findings);
443
556
  report.limitations.push(...fileResults.flatMap(result => result.limitations));
444
557
  report.execution = { fileConcurrency, maxRetrievalRounds };
558
+ report.readerLimits = { maxRounds: maxRetrievalRounds };
445
559
  await options.onProgress?.({stage:'batch-complete',snapshotId:input.id,files:report.files,findings:report.findings,limitations:report.limitations});
446
560
  for (const interaction of report.interactions) if (interaction.status !== 'completed') report.limitations.push({ text: '跨文件综合未完成:' + interaction.groupId + ' (' + interaction.status + ')' });
447
561
  if (report.grouping?.links.some(l => l.split)) report.limitations.push({ text: '跨组关系未综合覆盖;关联分组不代表全部跨文件交互覆盖' });
@@ -458,6 +572,8 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
458
572
  }
459
573
  report.regressionCoverage = Object.fromEntries(['introduced', 'preexisting', 'uncertain', 'unassessed'].map(classification => [classification, report.findings.filter(f => (f.verification?.regression?.classification ?? 'unassessed') === classification).length]));
460
574
  report.findingGroups = groupDuplicateFindings(report.findings);
575
+ for (const state of report.files) state.resumeEligible = state.status === 'completed' && !state.stageFailures?.length && !state.reviewPasses?.some(pass => pass.status !== 'completed') && !state.windowCoverage?.pending?.length && !state.retrievalLimited && report.findings.filter(f => f.fileId === state.fileId).every(f => f.verification?.status === 'completed') && report.interactions.filter(item => item.fileIds?.includes(state.fileId)).every(item => item.status === 'completed');
576
+ await options.onResumeCheckpoint?.(report);
461
577
  report.followup = coverageFollowup(report);
462
578
  if (scope) report.readerBudget = { ...scope.usage(), ...scope.budget(), ...readerLimits };
463
579
  report.diagnostics = reviewDiagnostics(report, { reviewWallMs: performance.now() - startedAt });
@@ -491,7 +607,7 @@ function renderFinding(output, finding, index, duplicates) {
491
607
  const verification = finding.verification;
492
608
  if (verification) {
493
609
  const label = verification.status !== 'completed' ? '未完成' : ({ supported: '模型复核支持', refuted: '模型复核反驳(保留候选供追溯)', uncertain: '不确定' }[verification.verdict] ?? '未知');
494
- output.push('- 复核状态:' + label + ';不是事实或因果证明');
610
+ output.push('- 复核状态:' + label + (finding.reused ? '(相同快照的既往轮次结果,本次未重新取证)' : '') + ';不是事实或因果证明');
495
611
  if (verification.reason) output.push('- 复核理由:' + safe(verification.reason));
496
612
  for (const ref of verification.evidence ?? []) output.push('- 复核证据:' + safe(ref.sourceId) + ':' + ref.start + '(' + ref.count + '行) · SHA256 ' + safe(ref.hash) + ' · ' + safe(ref.text));
497
613
  }
@@ -1,12 +1,43 @@
1
1
  import { riskPlanningDecision } from './risk-plan.mjs';
2
2
  import { INLINE_RISK_INSTRUCTIONS } from './prompts.mjs';
3
3
  import { initialInputBudget } from './input-budget.mjs';
4
+ import { changeMap } from './change-map.mjs';
5
+ import { changeWindows } from './change-windows.mjs';
6
+ import { sourceSegments } from './source-segments.mjs';
7
+ import { isDeepStrictEqual } from 'node:util';
4
8
 
5
9
  export function riskPlanningMode(value = 'separate') {
6
10
  if (!['auto', 'separate'].includes(value)) throw new Error('riskPlanningMode must be auto or separate');
7
11
  return value;
8
12
  }
9
13
 
14
+ // Compare the entire host projection, not just edit IDs: a claimed edit can be
15
+ // split across several windows and both sides (including zero counts) must match.
16
+ function completeWindowScope(file, prepared, payload) {
17
+ if (payload.sourceMode !== 'change-windows' || prepared.metadata?.sourceMode !== 'change-windows' ||
18
+ prepared.synthesis !== undefined || prepared.metadata?.windowBlocked?.length ||
19
+ (prepared.metadata?.batchCount !== undefined && prepared.metadata.batchCount !== 1) ||
20
+ (prepared.batches !== undefined && prepared.batches.length !== 1)) return false;
21
+ const changes = changeMap(file.left.text, file.right.text);
22
+ if (changes.status !== 'changed' || changes.precision !== 'exact' || !changes.edits.length ||
23
+ !isDeepStrictEqual(prepared.changes, changes) || !isDeepStrictEqual(payload.changes, changes)) return false;
24
+ const projected = changeWindows(file, changes);
25
+ if (!projected) return false;
26
+ const windows = sourceSegments(projected), ids = windows.map(w => w.id);
27
+ if (!isDeepStrictEqual(payload.windows, windows) || !isDeepStrictEqual(prepared.metadata.windowIds, ids)) return false;
28
+ const referenced = new Set(windows.flatMap(w => w.editIds));
29
+ if (referenced.size !== changes.edits.length || changes.edits.some(e => !referenced.has(e.id))) return false;
30
+ const compact = { ...file, left: { ...file.left, text: undefined }, right: { ...file.right, text: undefined } };
31
+ // JSON transport omits undefined; retained hashes/encoding/existence/identity
32
+ // must still be identical to the approved file, never another source copy.
33
+ if (!isDeepStrictEqual(payload.file, JSON.parse(JSON.stringify(compact)))) return false;
34
+ if (prepared.batches !== undefined) {
35
+ const batch = prepared.batches[0];
36
+ if (batch.payload !== prepared.payload || !isDeepStrictEqual(batch.windowIds, ids)) return false;
37
+ }
38
+ return true;
39
+ }
40
+
10
41
  // One pure decision over the actual prepared envelope, shared by preview and execution.
11
42
  // It never reads sources, changes payloads, expands scope or forces window preparation.
12
43
  export function chooseRiskStrategy(input, file, prepared, options) {
@@ -14,14 +45,23 @@ export function chooseRiskStrategy(input, file, prepared, options) {
14
45
  const decision = options.enableRiskPlanning === true
15
46
  ? options.skipSmallRiskPlans === true ? riskPlanningDecision([file]) : { required: true, reason: 'unconditional' }
16
47
  : { required: false, reason: 'disabled' };
17
- const fallback = { mode: decision.required ? 'separate' : 'disabled', decision, instructions: options.instructions };
18
- if (mode !== 'auto' || !decision.required || input.projectScope || file.reviewMode === 'project' || (options.round ?? 1) !== 1 ||
19
- prepared.batches !== undefined || prepared.synthesis !== undefined || prepared.metadata?.sourceMode) return fallback;
20
- const payload = JSON.parse(prepared.payload);
21
- if (payload.projectScope || payload.sourceMode || payload.windows || payload.file?.id !== file.id ||
22
- typeof payload.file?.left?.text !== 'string' || typeof payload.file?.right?.text !== 'string' ||
23
- payload.file.left.text !== file.left.text || payload.file.right.text !== file.right.text) return fallback;
48
+ const fallback = fallbackReason => ({ mode: decision.required ? 'separate' : 'disabled', decision, instructions: options.instructions, fallbackReason });
49
+ if (mode !== 'auto') return fallback('mode-notauto');
50
+ if (!decision.required) return fallback(options.enableRiskPlanning === true ? 'smallchange' : 'disabled');
51
+ if (input.projectScope || file.reviewMode === 'project') return fallback('project');
52
+ if ((options.round ?? 1) !== 1) return fallback('laterround');
53
+ if (prepared.batches?.length > 1) return fallback('multi-batch');
54
+ if (typeof file.left?.text !== 'string' || typeof file.right?.text !== 'string') return fallback('incomplete-edit-scope');
55
+ let payload;
56
+ try { payload = JSON.parse(prepared.payload); } catch { return fallback('incomplete-edit-scope'); }
57
+ if (!payload || payload.projectScope || payload.file?.id !== file.id) return fallback('incomplete-edit-scope');
58
+ if (payload.sourceMode === 'change-windows') {
59
+ if (payload.snapshotId !== input.id || !completeWindowScope(file, prepared, payload)) return fallback('incomplete-edit-scope');
60
+ } else if (payload.sourceMode || payload.windows || prepared.batches !== undefined || prepared.synthesis !== undefined ||
61
+ prepared.metadata?.sourceMode || payload.file?.left?.text !== file.left.text || payload.file?.right?.text !== file.right.text) {
62
+ return fallback('incomplete-edit-scope');
63
+ }
24
64
  const instructions = options.instructions + '\n' + INLINE_RISK_INSTRUCTIONS;
25
65
  const budget = initialInputBudget({ instructions, input: prepared.payload }, options.scope, options.maxInputBytes);
26
- return budget.fits ? { mode: 'inline', decision, instructions, budget } : fallback;
66
+ return budget.fits ? { mode: 'inline', decision, instructions, budget } : fallback('input-budget');
27
67
  }
@@ -0,0 +1,75 @@
1
+ import { retrievalRequest } from './retrieval-loop.mjs';
2
+ import { modelWireBudget } from './model-wire-budget.mjs';
3
+
4
+ const validLine = value => Number.isSafeInteger(value) && value > 0;
5
+ const capacity = () => Object.assign(new Error('Optional search seed unavailable'), { code: 'SEARCH_SEED_CAPACITY' });
6
+ const inside = (range, line) => validLine(range?.start) && Number.isSafeInteger(range.count) && range.count > 0 && typeof range.text === 'string' && range.start <= line && line < range.start + range.count;
7
+
8
+ function provided(payload, source, coordinate) {
9
+ if (['old', 'new'].includes(source.side)) {
10
+ const files = [payload.file, ...(Array.isArray(payload.relatedFiles) ? payload.relatedFiles : [])];
11
+ if (files.some(file => file?.path === source.path && file.id === source.fileId && typeof (source.side === 'old' ? file.left?.text : file.right?.text) === 'string')) return true;
12
+ if (payload.file?.path === source.path && payload.file.id === source.fileId && payload.sourceMode === 'change-windows' && Array.isArray(payload.windows)) return payload.windows.some(window => inside(window[source.side], coordinate));
13
+ }
14
+ if (['context', 'context-old'].includes(source.side)) return (payload.context ?? []).some(context => context?.path === source.path && typeof context[source.side === 'context-old' ? 'oldText' : 'text'] === 'string');
15
+ return false;
16
+ }
17
+
18
+ // Host-only follow-up coalescing. Search fragments are navigation, not evidence;
19
+ // only a fresh approved read can supply the exact original body. No cache/audit
20
+ // or receipts are created here. The caller admits/audits outgoing seeds normally.
21
+ export function searchSeeds(scope, request, records, { existing = [], maxReads = 2, maxInputBytes = 98304, buildRequest = values => retrievalRequest(request, scope, values), signal = request?.signal } = {}) {
22
+ if (!Number.isSafeInteger(maxReads) || maxReads < 0 || maxReads > 2 || !Number.isSafeInteger(maxInputBytes) || maxInputBytes < 1 || !Array.isArray(records) || !Array.isArray(existing) || typeof buildRequest !== 'function' || signal !== undefined && !(signal instanceof AbortSignal) || request?.signal !== undefined && !(request.signal instanceof AbortSignal)) throw new Error('Invalid search seed options');
23
+ const check = () => { request?.signal?.throwIfAborted(); signal?.throwIfAborted(); };
24
+ check();
25
+ const seeds = []; let attempts = 0;
26
+ if (!maxReads) return { seeds, attempts };
27
+ if (typeof request?.input !== 'string' || typeof request.instructions !== 'string') throw new Error('Invalid search seed request');
28
+ const payload = JSON.parse(request.input);
29
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload) || typeof payload.snapshotId !== 'string' || !payload.snapshotId || typeof scope?.transaction !== 'function' || typeof scope.preflight !== 'function' || typeof scope.budget !== 'function') return { seeds, attempts };
30
+ const catalog = scope.catalog(), seen = new Set();
31
+ for (const record of records) {
32
+ check();
33
+ if (record?.request?.kind !== 'search' || record.result?.snapshotId !== payload.snapshotId || !Array.isArray(record.result.matches) || record.result.matches.length > 100) continue;
34
+ for (const match of record.result.matches) {
35
+ check();
36
+ if (attempts >= maxReads) return { seeds, attempts };
37
+ const sources = catalog.filter(source => source.id === match?.sourceId);
38
+ if (sources.length !== 1 || typeof match.hash !== 'string' || !/^[a-f0-9]{64}$/.test(match.hash) || !validLine(match.line)) continue;
39
+ const source = sources[0];
40
+ if (!validLine(source.lineCount) || match.line > source.lineCount || source.hash !== undefined && source.hash !== match.hash || record.request.sourceIds !== undefined && (!Array.isArray(record.request.sourceIds) || !record.request.sourceIds.includes(source.id))) continue;
41
+ const key = JSON.stringify([source.id, match.line]);
42
+ if (seen.has(key)) continue;
43
+ seen.add(key);
44
+ if (provided(payload, source, match.line)) continue;
45
+ if ([...existing, ...seeds].some(item => item?.request?.kind === 'read' && item.result?.snapshotId === payload.snapshotId && item.result.sourceId === source.id && item.result.hash === match.hash && inside(item.result, match.line))) continue;
46
+ const budget = scope.budget();
47
+ // Keep four normal reader calls and 16KiB output headroom; this is not
48
+ // authority expansion or a guarantee that arbitrary final evidence fits.
49
+ if (budget.remainingCalls <= 4 || budget.remainingOutputBytes <= 16 * 1024) return { seeds, attempts };
50
+ const start = Math.max(1, match.line - 3);
51
+ const operation = { kind: 'read', id: source.id, start, count: Math.min(24, source.lineCount - start + 1) };
52
+ attempts++;
53
+ try {
54
+ const item = scope.transaction(() => {
55
+ check(); scope.preflight([operation]);
56
+ const result = scope.read(operation);
57
+ check();
58
+ if (result.snapshotId !== record.result.snapshotId || result.sourceId !== source.id || result.hash !== match.hash || !inside(result, match.line) || result.receiptId !== undefined) throw capacity();
59
+ const next = buildRequest([...existing, ...seeds, { request: operation, result }]);
60
+ check();
61
+ const remaining = scope.budget();
62
+ if (typeof next?.input !== 'string' || typeof next.instructions !== 'string') throw new Error('Invalid search seed request builder');
63
+ if ((request.nativeOutputTools === true ? !modelWireBudget(next, maxInputBytes).fits : Buffer.byteLength(next.input) + Buffer.byteLength(next.instructions) > maxInputBytes) || remaining.remainingCalls < 4 || remaining.remainingOutputBytes < 16 * 1024) throw capacity();
64
+ return { request: operation, result };
65
+ });
66
+ seeds.push(item);
67
+ } catch (error) {
68
+ check();
69
+ if (!['SEARCH_SEED_CAPACITY', 'READER_CALL_BUDGET_EXCEEDED', 'READER_OUTPUT_BUDGET_EXCEEDED'].includes(error?.code)) throw error;
70
+ }
71
+ }
72
+ }
73
+ check();
74
+ return { seeds, attempts };
75
+ }