${escape(value?.model || result[side].model || 'Unspecified model')}
${winner === side ? preferred : ''} + const response = (side, value, status, winner, error, badge = preferredBadge(decision)) => `${escape(value?.model || result[side].model || 'Unspecified model')}
${winner === side ? badge : ''} ${escape(status)}${label(side)}${result.comparisonKind === 'prompt' ? ' prompt' : ''}
${tokens(value)}${escape(error || 'No response available.')}
`}Historical budget-limited investigation: ${escape(value.investigationBudget.reasons.join(' '))}
` : ''}${escape(result[side].model || 'Unspecified model')}
${winner === side ? preferred : ''}${escape(result[side].model || 'Unspecified model')}
${winner === side ? preferredBadge( + test ? { preference: winner } : decision, true) : ''}${label(side)}${result.comparisonKind === 'prompt' ? ' prompt' : ''}
Preferred reflects the quality review, not lower token usage, cost, or latency. Tokens accumulate across turns as evidence is sent again.
Run ${pair.number}
${escape(pair.evaluationStatus)}Generation order: ${escape(pair.order.join(' then '))}
+${test.plan.parallel ? 'Simultaneous generation (launch order)' : 'Generation order'}: ${escape(pair.order.join(' then '))}
${escape(pair.evaluationError)}
` : ''}Quality review
${assessment(result.evaluation)}Model reports
+ decision.preference, pair[side].error, preferredBadge(decision))).join('')}${escape(check.phrase)}: baseline ${check.baseline ? 'Pass' : 'Fail'}, candidate ${check.candidate ? 'Pass' : 'Fail'}
`).join('')} + ${pair.evaluationError ? `${escape(pair.evaluationError)}
` : ''}Quality review
${assessment(result.evaluation, decision, result.verificationResults)}Model reports
PROMPT EDIT REVIEW
@@ -125,7 +147,7 @@ function asHtml(result, { css, markdownScript }) { ${details('Output changes', changesOnly(result.spans, result.outputPassages, 'output'))}` : ''; const data = JSON.stringify(markdown).replaceAll('<', '\\u003c').replaceAll('\u2028', '\\u2028').replaceAll('\u2029', '\\u2029'); return ` - +⇄ CompareLab
${kind} · Offline assessment report
CompareLab
${kind} · Offline assessment report
${kind.toUpperCase()}
${title}
${test ? escape(summary?.note) : 'A practical, task-specific comparison. Recommendations and cited evidence still require human verification.'}
diff --git a/src/tools/agent.js b/src/tools/agent.js index 20badf2..7335c54 100644 --- a/src/tools/agent.js +++ b/src/tools/agent.js @@ -84,7 +84,12 @@ async function verifyReadOnlyTools(vscode, tools) { return verified; } -async function runToolAgent(vscode, input, tools, runTurn, token, progress = () => {}) { +async function runToolAgent(vscode, input, tools, runTurn, token, progress = () => {}, limits) { + if (limits && (!Number.isSafeInteger(limits.modelRequests) || limits.modelRequests < 1 || + !Number.isSafeInteger(limits.toolCalls) || limits.toolCalls < 1 || + limits.evidenceBytes !== undefined && (!Number.isSafeInteger(limits.evidenceBytes) || limits.evidenceBytes < 1))) { + throw new Error('Invalid evidence-resolution limits.'); + } const verified = readOnlyCatalogs.get(tools); if (!verified || !isDeepStrictEqual(verified, toolCatalog(tools))) { throw new Error('Tools must be freshly verified read-only for this test. Saved selections and per-call approval cannot bypass this requirement.'); @@ -104,9 +109,23 @@ async function runToolAgent(vscode, input, tools, runTurn, token, progress = () }; const catalog = validateToolCatalog(toolCatalog(tools)); const allowed = new Map(catalog.map(tool => [tool.name, tool])); + const policy = limits ? AGENT_PROMPT.replace('CompareLab imposes no tool-call, model-turn, investigation-time, or tool-result truncation budget.', + 'This evidence-resolution round allows at most ' + limits.modelRequests + ' model requests and ' + limits.toolCalls + + ' tool calls. Collect the most relevant observations and finish within that budget; missing evidence must remain explicit.') : AGENT_PROMPT; const conversation = [...(input.systemPrompt ? [{ role: 'system', content: input.systemPrompt }] : []), - { role: 'user', content: input.prompt }, { role: 'user', content: AGENT_PROMPT }]; + { role: 'user', content: input.prompt }, { role: 'user', content: policy }]; const toolEvidence = []; + const exceedsEvidenceLimit = entry => limits?.evidenceBytes && + Buffer.byteLength(JSON.stringify([...toolEvidence, entry])) > limits.evidenceBytes; + const retainFailure = entry => { + if (!exceedsEvidenceLimit(entry)) { + toolEvidence.push(entry); + return; + } + const marker = { tool: String(entry.tool || '').slice(0, 200), collectedAt: entry.collectedAt, isError: true, + content: 'Tool failure exceeded the evidence-resolution byte limit; details were not retained.' }; + if (!exceedsEvidenceLimit(marker)) toolEvidence.push(marker); + }; const usageTurns = []; const started = Date.now(); const totals = { inputTokens: 0, outputTokens: 0, estimatedInputTokens: 0, estimatedOutputTokens: 0 }; @@ -114,6 +133,7 @@ async function runToolAgent(vscode, input, tools, runTurn, token, progress = () try { while (true) { check(); + if (limits && modelRequests >= limits.modelRequests) throw new Error('Evidence resolution exhausted its model-request budget.'); progress('Tool-enabled run: model request ' + (++modelRequests) + ' | Tool calls: ' + toolEvidence.length); const result = await runCancellable(() => runTurn(conversation, catalog, cancellation.token)); check(); @@ -141,6 +161,7 @@ async function runToolAgent(vscode, input, tools, runTurn, token, progress = () conversation.push({ role: 'assistant', content: result.response || '', toolCalls: calls }); for (const call of calls) { check(); + if (limits && toolEvidence.length >= limits.toolCalls) throw new Error('Evidence resolution exhausted its tool-call budget.'); const approval = await runCancellable(() => vscode.window.showWarningMessage('CompareLab: review this tool request?', { modal: true, detail: (input.runLabel || 'Tool-enabled run') + ' | Model: ' + (input.model || '(selected model)') + '\n\n' + call.name + '\n' + JSON.stringify(call.input, null, 2) + @@ -172,9 +193,9 @@ async function runToolAgent(vscode, input, tools, runTurn, token, progress = () } catch (error) { const repeated = previousRejections.some(entry => entry.tool === call.name && isDeepStrictEqual(entry.input, call.input)); if (cancellation.token.isCancellationRequested || error instanceof vscode.CancellationError || !isInvalidArguments(error) || repeated) { - toolEvidence.push({ tool: call.name, input: call.input, collectedAt: new Date().toISOString(), - content: String(error.message || error || 'Tool invocation interrupted.') + '\nThe invocation failed or was interrupted; side effects may have occurred.', - isError: true }); + retainFailure({ tool: call.name, input: call.input, collectedAt: new Date().toISOString(), + content: String(error.message || error || 'Tool invocation interrupted.') + + '\nThe invocation failed or was interrupted; side effects may have occurred.', isError: true }); check(); if (!repeated || !isInvalidArguments(error)) throw error; throw new Error('Tool ' + call.name + ' rejected the same arguments again after correction feedback: ' + error.message); @@ -185,6 +206,11 @@ async function runToolAgent(vscode, input, tools, runTurn, token, progress = () } const entry = { tool: call.name, input: call.input, collectedAt: new Date().toISOString(), content, ...(isError ? { isError: true, inputSchema: allowed.get(call.name).inputSchema } : {}) }; + if (exceedsEvidenceLimit(entry)) { + retainFailure({ tool: call.name, collectedAt: entry.collectedAt, isError: true, + content: 'Tool result exceeded the evidence-resolution byte limit and was not retained or sent to the model.' }); + throw new Error('Evidence resolution exceeded its retained-evidence limit. No oversized result was used as evidence.'); + } toolEvidence.push(entry); conversation.push({ role: 'tool', toolCallId: call.callId, content: JSON.stringify(entry) }); } diff --git a/src/webview/panel.js b/src/webview/panel.js index 216c12a..2a59504 100644 --- a/src/webview/panel.js +++ b/src/webview/panel.js @@ -52,10 +52,11 @@ function panelHtml(vscode, panel, context) { const media = (...parts) => panel.webview.asWebviewUri(vscode.Uri.joinPath(context.extensionUri, 'media', ...parts)); return ` - +⇄ CompareLab
Visual, context-aware model comparisons.
CompareLab
Compare models. Test prompt changes.