diff --git a/src/chrome/src/agent/agent.js b/src/chrome/src/agent/agent.js index b2f852aeb..6dc2a93b3 100644 --- a/src/chrome/src/agent/agent.js +++ b/src/chrome/src/agent/agent.js @@ -761,29 +761,38 @@ export class Agent extends LoopDetector { return this.conversationIds.get(tabId) || null; } + /** + * Snapshot the effective runtime settings for a run. Anything we cannot + * observe is omitted rather than guessed: an absent field reads as "unknown" + * in a dump, while a hard `false`/'ask' would assert a setting the run never + * actually had — exactly the misattribution this metadata exists to prevent. + */ _runtimeTraceConfig(provider, { tabId = null, mode = null } = {}) { let extensionVersion = ''; - let promptTier = 'full'; try { extensionVersion = chrome.runtime.getManifest().version || ''; } catch {} - try { promptTier = provider?.promptTier || 'full'; } catch {} - const effectiveMode = mode - || (tabId != null ? this._effectiveRunMode(tabId) : 'ask'); + const effectiveMode = mode || (tabId != null ? this._effectiveRunMode(tabId) : null); return normalizeRuntimeTraceConfig({ extension_version: extensionVersion, browser_target: 'chrome', - mode: effectiveMode, - prompt_tier: promptTier, + ...(effectiveMode ? { mode: effectiveMode } : {}), + prompt_tier: this._resolvePromptTier(provider), screenshot_redaction: this.screenshotRedaction === true, strict_secret_mode: this.strictSecretMode === true, plan_before_act_mode: this._normalizePlanBeforeActMode(this.planBeforeActMode), auto_screenshot: this.autoScreenshot, use_site_adapters: this.useSiteAdapters === true, web_mcp_enabled: this.webMcpEnabled === true, - api_mutations_allowed: tabId != null && this.apiAllowedTabs.has(tabId), user_memory_enabled: this.userMemoryEnabled === true, - selection_grounded: tabId != null && this.selectionGroundingScopes.has(tabId), + // Per-tab authorizations only mean something in a tab's context. + ...(tabId != null ? { + api_mutations_allowed: this.apiAllowedTabs.has(tabId), + selection_grounded: this.selectionGroundingScopes.has(tabId), + } : {}), image_detail: this.imageDetail, - max_agent_steps: this.maxSteps, + // The steps slider stores 0 for "unlimited", which the agent hydrates as + // Infinity. Round-trip that back to 0 so an unlimited run records as + // unlimited instead of being dropped as a non-integer. + max_agent_steps: Number.isFinite(this.maxSteps) ? this.maxSteps : 0, max_image_dimension: this.maxImageDimension, max_screenshots_per_turn: this.maxScreenshotsPerTurn, }); @@ -9357,14 +9366,15 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d } /** - * Resolve the active provider's prompt tier ('compact' | 'mid' | 'full'). - * The provider getter already forces 'full' for cloud providers and applies - * the per-category defaults (local → 'mid'); we just guard the case where - * no provider is ready yet (fall back to the full prompt). + * Resolve a provider's prompt tier ('compact' | 'mid' | 'full'), defaulting + * to the active provider. The provider getter already forces 'full' for + * cloud providers and applies the per-category defaults (local → 'mid'); we + * just guard the case where no provider is ready yet (fall back to the full + * prompt). */ - _resolvePromptTier() { + _resolvePromptTier(provider = null) { try { - return this.providerManager.getActive().promptTier || 'full'; + return (provider || this.providerManager.getActive()).promptTier || 'full'; } catch { return 'full'; } } diff --git a/src/chrome/src/trace/runtime-config.js b/src/chrome/src/trace/runtime-config.js index 5f2eca831..b9e5fa2bd 100644 --- a/src/chrome/src/trace/runtime-config.js +++ b/src/chrome/src/trace/runtime-config.js @@ -17,9 +17,13 @@ const BOOLEAN_FIELDS = Object.freeze([ 'selection_grounded', ]); +// Bounds keep the payload sane, not to re-validate settings: each range is a +// superset of what the agent's own normalizers can hold (steps ≤ 200 with 0 = +// unlimited, dimension ≤ 2048, screenshots ≤ 5), so a legitimate setting is +// never silently dropped for being out of range. const INTEGER_RANGES = Object.freeze({ max_agent_steps: [0, 10_000], - max_image_dimension: [256, 16_384], + max_image_dimension: [1, 16_384], max_screenshots_per_turn: [0, 1_000], }); diff --git a/src/firefox/src/agent/agent.js b/src/firefox/src/agent/agent.js index c55b7865e..123c34d42 100644 --- a/src/firefox/src/agent/agent.js +++ b/src/firefox/src/agent/agent.js @@ -860,29 +860,38 @@ export class Agent extends LoopDetector { return this.conversationIds.get(tabId) || null; } + /** + * Snapshot the effective runtime settings for a run. Anything we cannot + * observe is omitted rather than guessed: an absent field reads as "unknown" + * in a dump, while a hard `false`/'ask' would assert a setting the run never + * actually had — exactly the misattribution this metadata exists to prevent. + */ _runtimeTraceConfig(provider, { tabId = null, mode = null } = {}) { let extensionVersion = ''; - let promptTier = 'full'; try { extensionVersion = browser.runtime.getManifest().version || ''; } catch {} - try { promptTier = provider?.promptTier || 'full'; } catch {} - const effectiveMode = mode - || (tabId != null ? this._effectiveRunMode(tabId) : 'ask'); + const effectiveMode = mode || (tabId != null ? this._effectiveRunMode(tabId) : null); return normalizeRuntimeTraceConfig({ extension_version: extensionVersion, browser_target: 'firefox', - mode: effectiveMode, - prompt_tier: promptTier, + ...(effectiveMode ? { mode: effectiveMode } : {}), + prompt_tier: this._resolvePromptTier(provider), screenshot_redaction: this.screenshotRedaction === true, strict_secret_mode: this.strictSecretMode === true, plan_before_act_mode: this._normalizePlanBeforeActMode(this.planBeforeActMode), auto_screenshot: this.autoScreenshot, use_site_adapters: this.useSiteAdapters === true, web_mcp_enabled: this.webMcpEnabled === true, - api_mutations_allowed: tabId != null && this.apiAllowedTabs.has(tabId), user_memory_enabled: this.userMemoryEnabled === true, - selection_grounded: tabId != null && this.selectionGroundingScopes.has(tabId), + // Per-tab authorizations only mean something in a tab's context. + ...(tabId != null ? { + api_mutations_allowed: this.apiAllowedTabs.has(tabId), + selection_grounded: this.selectionGroundingScopes.has(tabId), + } : {}), image_detail: this.imageDetail, - max_agent_steps: this.maxSteps, + // The steps slider stores 0 for "unlimited", which the agent hydrates as + // Infinity. Round-trip that back to 0 so an unlimited run records as + // unlimited instead of being dropped as a non-integer. + max_agent_steps: Number.isFinite(this.maxSteps) ? this.maxSteps : 0, max_image_dimension: this.maxImageDimension, max_screenshots_per_turn: this.maxScreenshotsPerTurn, }); @@ -8152,14 +8161,15 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d } /** - * Resolve the active provider's prompt tier ('compact' | 'mid' | 'full'). - * The provider getter already forces 'full' for cloud providers and applies - * the per-category defaults (local → 'mid'); we just guard the case where - * no provider is ready yet (fall back to the full prompt). + * Resolve a provider's prompt tier ('compact' | 'mid' | 'full'), defaulting + * to the active provider. The provider getter already forces 'full' for + * cloud providers and applies the per-category defaults (local → 'mid'); we + * just guard the case where no provider is ready yet (fall back to the full + * prompt). */ - _resolvePromptTier() { + _resolvePromptTier(provider = null) { try { - return this.providerManager.getActive().promptTier || 'full'; + return (provider || this.providerManager.getActive()).promptTier || 'full'; } catch { return 'full'; } } diff --git a/src/firefox/src/trace/runtime-config.js b/src/firefox/src/trace/runtime-config.js index 5f2eca831..b9e5fa2bd 100644 --- a/src/firefox/src/trace/runtime-config.js +++ b/src/firefox/src/trace/runtime-config.js @@ -17,9 +17,13 @@ const BOOLEAN_FIELDS = Object.freeze([ 'selection_grounded', ]); +// Bounds keep the payload sane, not to re-validate settings: each range is a +// superset of what the agent's own normalizers can hold (steps ≤ 200 with 0 = +// unlimited, dimension ≤ 2048, screenshots ≤ 5), so a legitimate setting is +// never silently dropped for being out of range. const INTEGER_RANGES = Object.freeze({ max_agent_steps: [0, 10_000], - max_image_dimension: [256, 16_384], + max_image_dimension: [1, 16_384], max_screenshots_per_turn: [0, 1_000], }); diff --git a/test/run.js b/test/run.js index 7419205c4..c12570096 100644 --- a/test/run.js +++ b/test/run.js @@ -4139,12 +4139,22 @@ test('trace record and JSON exports carry WebBrain version metadata', () => { assert.match(recorder, /runtimeConfig: normalizeRuntimeTraceConfig\(meta\.runtimeConfig\)/, `${label}: run record should retain only allowlisted runtime settings`); assert.match(agent, new RegExp(`webbrainVersion: ${runtimeName}\\.runtime\\.getManifest\\(\\)\\.version`), `${label}: trace start should read the runtime manifest`); assert.match(agent, /runtimeConfig: this\._runtimeTraceConfig\(provider, \{ tabId, mode \}\)/, `${label}: trace start should snapshot effective runtime settings`); + assert.match( + agent, + /runtimeConfig: this\._runtimeTraceConfig\(this\.providerManager\?\.getActive\?\.\(\), \{\s*tabId,\s*mode: 'act',\s*\}\)/, + `${label}: workflow runs should snapshot effective runtime settings too`, + ); assert.match(traceUi, new RegExp(`exportedByWebBrainVersion: ${runtimeName}\\.runtime\\.getManifest\\(\\)\\.version`), `${label}: JSON export should identify the exporting build`); assert.match(traceUi, /schema: 'webbrain-trace\/1'/, `${label}: additive version metadata should retain the v1 schema`); } }); test('runtime trace config is versioned, bounded, and secret-free in both browsers', () => { + assert.equal( + fs.readFileSync(path.join(ROOT, 'src/chrome/src/trace/runtime-config.js'), 'utf8'), + fs.readFileSync(path.join(ROOT, 'src/firefox/src/trace/runtime-config.js'), 'utf8'), + 'chrome and firefox runtime trace config modules must remain byte-identical', + ); const candidate = { schema_version: 99, extension_version: '24.7.0-beta.1', @@ -4196,10 +4206,29 @@ test('runtime trace config is versioned, bounded, and secret-free in both browse browser_target: 'safari', mode: 'admin', max_agent_steps: Infinity, - max_image_dimension: 42, + max_image_dimension: 1568.5, + max_screenshots_per_turn: -1, screenshot_redaction: 'yes', }); assert.deepEqual(rejected, { schema_version: 1 }); + + // Integer ranges exist to bound the payload, not to re-validate settings: + // every value the agent's own normalizers can produce must survive, or the + // dump silently loses the setting it was added to attribute. + for (const [field, value] of [ + ['max_agent_steps', 0], + ['max_agent_steps', 200], + ['max_image_dimension', 1], + ['max_image_dimension', 2048], + ['max_screenshots_per_turn', 0], + ['max_screenshots_per_turn', 5], + ]) { + assert.equal( + RuntimeTraceConfigCh.normalizeRuntimeTraceConfig({ [field]: value })[field], + value, + `${field}=${value} is producible by the agent and must not be dropped`, + ); + } }); test('trace recorders normalize done only from explicit loop error evidence', () => { @@ -32434,6 +32463,23 @@ test('WebBrain Cloud groups every generation in a stable conversation session wi assert.equal(main.webbrainRuntimeConfig?.strict_secret_mode, true, `${label}: strict secret setting missing`); assert.equal(main.webbrainRuntimeConfig?.api_mutations_allowed, true, `${label}: per-tab API authorization missing`); assert.ok(!JSON.stringify(main.webbrainRuntimeConfig).includes('apiKey'), `${label}: runtime metadata must remain an allowlist`); + assert.equal(main.webbrainRuntimeConfig?.max_agent_steps, agent.maxSteps, `${label}: step budget missing`); + + // "Unlimited" steps hydrate as Infinity; record the stored 0 sentinel so an + // unlimited run is attributable instead of missing the field entirely. + const previousMaxSteps = agent.maxSteps; + agent.maxSteps = Infinity; + const unlimited = agent._cloudGenerationOptions(cloud, {}, { tabId, generationName: 'main' }); + assert.equal(unlimited.webbrainRuntimeConfig?.max_agent_steps, 0, `${label}: unlimited step budget should record as 0`); + agent.maxSteps = previousMaxSteps; + + // Without a tab there is no observable mode or per-tab authorization, so + // those fields must be absent rather than asserted as ask/false. + const untabbed = agent._runtimeTraceConfig(cloud); + assert.equal('mode' in untabbed, false, `${label}: unknown mode should be omitted, not guessed`); + assert.equal('api_mutations_allowed' in untabbed, false, `${label}: per-tab API authorization should be omitted without a tab`); + assert.equal('selection_grounded' in untabbed, false, `${label}: selection grounding should be omitted without a tab`); + assert.equal(untabbed.browser_target, label, `${label}: browser target should survive without a tab`); const byoOptions = agent._cloudGenerationOptions({ config: { providerName: 'openai' } }, { temperature: 0 }, { tabId, generationName: 'memory' }); assert.deepEqual(byoOptions, { temperature: 0 }, `${label}: BYO provider received Cloud collection fields`);