diff --git a/src/chrome/src/agent/agent.js b/src/chrome/src/agent/agent.js index 8cfe4e74a..b2f852aeb 100644 --- a/src/chrome/src/agent/agent.js +++ b/src/chrome/src/agent/agent.js @@ -31,6 +31,7 @@ import { PDF_PASSTHROUGH_MAX_BYTES, } from './pdf-tools.js'; import * as trace from '../trace/recorder.js'; +import { normalizeRuntimeTraceConfig } from '../trace/runtime-config.js'; import { tracesToMarkdown } from './trace-export.js'; import { solveCaptcha, detectCaptcha, injectToken, captchaParamError, captchaTypesMatch, captchaWebsiteUrl } from './captcha-solver.js'; import { isCapsolverEnabled, normalizeCapsolverApiKey } from './capsolver-config.js'; @@ -84,6 +85,7 @@ const TOKENS_PER_MILLION = 1_000_000; const DEFAULT_INPUT_COST_PER_MILLION_USD = 3; const DEFAULT_OUTPUT_COST_PER_MILLION_USD = 15; const DONE_OUTCOMES = new Set(['success', 'partial', 'failed']); +const LOCAL_CANCELLATION_ASSISTANT_RE = /^\[?Stopped by user(?: before (?:the run started|executing requested tool calls))?\.?\]?$/; // Appended to the system prompt of every selection-grounded model request. // The scope hides the page and disables tools, so the model must explain the // boundary instead of guessing when a follow-up reaches beyond the selection. @@ -125,6 +127,41 @@ function isBrowserNewTabUrl(url) { return BROWSER_NEW_TAB_URL_PREFIXES.some(prefix => value.startsWith(prefix)); } +// Only read a 3-digit number as a status code where it actually reads like +// one — prefixed by a status word, leading the message, or parenthesized. +// A bare scan would turn "max_tokens 512" into a 5xx and recommend a retry +// that can only fail the same way. +const PLANNER_FAILURE_STATUS_PATTERNS = [ + /\b(?:error|http|status|code|returned|response)\s*[:#-]?\s*(\d{3})\b/i, + /^\s*\(?(\d{3})\)?\b/, + /\((\d{3})\)/, +]; +const PLANNER_AUTH_FAILURE_RE = /\b(?:unauthori[sz]ed|unauthenticated|forbidden|permission denied|invalid credentials|(?:invalid|incorrect|missing|expired)[\s_-]*api[\s_-]*key|api[\s_-]*key[\s_-]*(?:is\s*)?(?:missing|invalid|incorrect|expired|not\s*set|required)|authentication|(?:expired|invalid)[\s_-]*token|token[\s_-]*expired)\b/i; +// "Failed to fetch" (Chrome) and "NetworkError…" (Firefox) are the shapes a +// dead local provider or an offline machine actually produces, so they have +// to land in the bucket that offers Retry first. +const PLANNER_TRANSIENT_FAILURE_RE = /\b(?:timed?\s*out|timeout|network\w*|failed to fetch|fetch failed|load failed|connection (?:closed|reset|refused|error)|econn\w+|socket hang up|temporar(?:y|ily)|unavailable|overloaded|rate[\s_-]?limit\w*|too many requests|bad gateway|gateway timeout)\b/i; + +function plannerRequestFailureKind(detail) { + const text = String(detail || ''); + let status = 0; + for (const pattern of PLANNER_FAILURE_STATUS_PATTERNS) { + const match = text.match(pattern); + if (match) { + status = Number(match[1]); + break; + } + } + if (status === 401 || status === 403) return 'auth'; + if ([408, 425, 429].includes(status) || (status >= 500 && status <= 599)) return 'transient'; + if (PLANNER_AUTH_FAILURE_RE.test(text)) return 'auth'; + // A remaining 4xx is a request the provider rejected on its merits; a retry + // of the same request is not the fix. + if (status >= 400 && status <= 499) return 'provider'; + if (PLANNER_TRANSIENT_FAILURE_RE.test(text)) return 'transient'; + return 'provider'; +} + /** * The WebBrain Agent — orchestrates multi-step LLM + tool-use loops. */ @@ -724,6 +761,34 @@ export class Agent extends LoopDetector { return this.conversationIds.get(tabId) || null; } + _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'); + return normalizeRuntimeTraceConfig({ + extension_version: extensionVersion, + browser_target: 'chrome', + mode: effectiveMode, + prompt_tier: promptTier, + 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), + image_detail: this.imageDetail, + max_agent_steps: this.maxSteps, + max_image_dimension: this.maxImageDimension, + max_screenshots_per_turn: this.maxScreenshotsPerTurn, + }); + } + _cloudGenerationOptions(provider, options = {}, { tabId = null, conversationId = null, generationName = 'main' } = {}) { if (String(provider?.config?.providerName || '').toLowerCase() !== 'webbrain-cloud') return options; const effectiveConversationId = conversationId || (tabId != null ? this.conversationIds.get(tabId) : null); @@ -732,6 +797,7 @@ export class Agent extends LoopDetector { ...options, webbrainSessionId: String(effectiveConversationId), webbrainGenerationName: String(generationName || 'main'), + webbrainRuntimeConfig: this._runtimeTraceConfig(provider, { tabId }), }; } @@ -6749,6 +6815,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d providerId: provider?.name, providerClass: provider?.constructor?.name, webbrainVersion: chrome.runtime.getManifest().version || '', + runtimeConfig: this._runtimeTraceConfig(provider, { tabId, mode }), userMessage: typeof userMessage === 'string' ? userMessage : JSON.stringify(userMessage).slice(0, 2000), tabUrl, tabTitle, @@ -6787,12 +6854,37 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d * planner can resolve follow-up references ("continue", "open the first * result"). Skips system / scratchpad / progress-ledger bookkeeping turns. */ + _isLocalCancellationText(content) { + return typeof content === 'string' + && LOCAL_CANCELLATION_ASSISTANT_RE.test(content.trim()); + } + + _localCancellationMessage(content) { + return { + role: 'assistant', + content, + webbrainLocalStatus: 'cancelled', + }; + } + + _isLocalConversationStatusMessage(message) { + if (message?.role !== 'assistant') return false; + return message.webbrainLocalStatus === 'cancelled' + || this._isLocalCancellationText(message.content); + } + + _modelVisibleConversationMessages(messages) { + if (!Array.isArray(messages)) return []; + return messages.filter(message => !this._isLocalConversationStatusMessage(message)); + } + _buildPlannerHistoryDigest(messages, maxChars = 1500) { if (!Array.isArray(messages) || messages.length === 0) return ''; const lines = []; for (const m of messages.slice(-10)) { if (!m || (m.role !== 'user' && m.role !== 'assistant')) continue; if (this._isPinnedAgentStateMessage(m)) continue; + if (this._isLocalConversationStatusMessage(m)) continue; const rawText = userMessageToText(m); const taskText = m.role === 'user' ? this._stripInjectedTaskContext(rawText) : rawText; const text = sanitizePlannerText(taskText, 300, { collapseWhitespace: true }); @@ -7163,6 +7255,9 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d reason: gate.reason, requestKind: gate.requestKind, requiresStateChange: gate.requiresStateChange, + // Carried so a caller can tell an auth failure from a transient one + // without re-parsing the message text. + ...(gate.failureKind ? { failureKind: gate.failureKind } : {}), }; } @@ -7238,6 +7333,60 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d ]; } + _plannerIntentConsistencyIssue(plan) { + if (!plan || !Array.isArray(plan.steps)) return null; + const tools = [...new Set( + plan.steps.flatMap(step => Array.isArray(step?.tools) ? step.tools : []) + .map(tool => String(tool || '').trim()) + .filter(Boolean) + )]; + if (plan.request_kind === 'respond' && tools.length > 0) { + return { kind: 'respond_with_tools', tools }; + } + const executionTools = tools.filter(tool => tool !== 'done'); + if (plan.request_kind === 'plan_only' && executionTools.length > 0) { + return { kind: 'plan_only_with_execution_tools', tools: executionTools }; + } + return null; + } + + _plannerIntentConsistencyRepairMessages(plannerMessages, issue) { + const issueKind = issue?.kind === 'respond_with_tools' + || issue?.kind === 'plan_only_with_execution_tools' + ? issue.kind + : 'unknown'; + return [ + ...plannerMessages, + { + role: 'user', + content: + '/no_think\n' + + `The previous JSON was parseable but its intent needs one consistency check (${issueKind}). ` + + 'Re-read only the authoritative User task above and output one corrected JSON object. ' + + 'Keep plan_only only when the user asked merely for a plan, outline, strategy, or discussion without authorizing execution. ' + + 'Use execute when producing the requested answer needs a fresh page, browser, network, memory, or scheduling tool; read-only execution still has requires_state_change false. ' + + 'Use respond only when existing conversation or working-note context is sufficient and list no tools. ' + + 'Do not infer permission for a mutation that the current user task did not authorize. No prose, markdown, tool calls, or reasoning text.', + }, + ]; + } + + _plannerIntentUnresolvedConsistencyIssue(plan, recheckedIssueKind = null) { + const issue = this._plannerIntentConsistencyIssue(plan); + if (!issue) return null; + // Repeating plan_only after the focused semantic recheck confirms that the + // user asked only for a plan. A respond plan that still lists tools can + // never be routed response-only, and a new issue after either repair has + // not received the focused consistency check. + if ( + issue.kind === 'plan_only_with_execution_tools' + && recheckedIssueKind === issue.kind + ) { + return null; + } + return issue; + } + _plannerIntentFailureMessage(runOptions = {}) { return sanitizePlannerText( runOptions?.intentFailureMessage @@ -7282,20 +7431,32 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d }; } - _plannerRequestFailure(error, onUpdate) { + _plannerRequestFailure(error, onUpdate, provider = null) { const detail = sanitizePlannerText( error?.message || String(error || 'Unknown planner request error.'), 500, { collapseWhitespace: true }, ); - const message = `Planner request failed before a valid response was available: ${detail}`; - onUpdate('warning', { message }); + const detailSentence = /[.!?]$/.test(detail) ? detail : `${detail}.`; + const message = `Planner request failed before a valid response was available: ${detailSentence} No tools ran.`; + const failureKind = plannerRequestFailureKind(detail); + onUpdate('warning', { + code: 'planner_request_failed', + message, + failureKind, + provider: sanitizePlannerText( + provider?.config?.label || provider?.name || provider?.config?.providerName || '', + 80, + { collapseWhitespace: true }, + ), + }); return { proceed: false, message, reason: 'planner_error', requestKind: 'respond', requiresStateChange: false, + failureKind, }; } @@ -7412,8 +7573,11 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d } if (this._checkAbort(tabId)) return { proceed: false, message: '[Stopped by user]', reason: 'cancelled' }; + let plannerRepairUsed = false; + let consistencyRepairKind = null; let plan = parsePlanFromContent(result.content, { requireIntent: true, locale }); if (!plan) { + plannerRepairUsed = true; onUpdate('thinking', { step: plannerStep, note: 'Understanding request… retrying JSON output' }); result = await this._chatWithCostAllowance( provider, @@ -7424,10 +7588,29 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d ); plan = parsePlanFromContent(result.content, { requireIntent: true, locale }); } + const consistencyIssue = !plannerRepairUsed + ? this._plannerIntentConsistencyIssue(plan) + : null; + if (consistencyIssue) { + plannerRepairUsed = true; + consistencyRepairKind = consistencyIssue.kind; + onUpdate('thinking', { step: plannerStep, note: 'Understanding request… rechecking tool-dependent intent' }); + result = await this._chatWithCostAllowance( + provider, + this._plannerIntentConsistencyRepairMessages(plannerMessages, consistencyIssue), + this._plannerChatOptions(provider, true, true), + costState, + { tabId, generationName: 'planner_intent' }, + ); + plan = parsePlanFromContent(result.content, { requireIntent: true, locale }); + } if (this._checkAbort(tabId)) return { proceed: false, message: '[Stopped by user]', reason: 'cancelled' }; if (!plan) { return this._plannerReadOnlyFallback(runOptions, onUpdate); } + if (this._plannerIntentUnresolvedConsistencyIssue(plan, consistencyRepairKind)) { + return this._plannerReadOnlyFallback(runOptions, onUpdate); + } if (plan.request_kind === 'respond') { return { proceed: true, @@ -7462,7 +7645,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d if (this._isCostAllowanceError(e)) { return { proceed: false, message: e.message, reason: 'cost_limit' }; } - return this._plannerRequestFailure(e, onUpdate); + return this._plannerRequestFailure(e, onUpdate, provider); } } @@ -7524,12 +7707,15 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d if (this._checkAbort(tabId)) { return { proceed: false, message: '[Stopped by user]' }; } + let plannerRepairUsed = false; + let consistencyRepairKind = null; let plan = parsePlanFromContent(result.content, { requireIntent: true, locale }); // Retry whenever the first attempt yields no parseable plan — empty // output, thinking-only output, OR non-JSON prose ("Sure, here's the // plan…"). The repair prompt exists precisely to coerce JSON out of that // prose case, so it must not be gated on emptiness/reasoning. (#1) if (!plan) { + plannerRepairUsed = true; onUpdate('thinking', { step: 0, note: 'Planning… retrying JSON output' }); result = await this._chatWithCostAllowance( provider, @@ -7540,6 +7726,22 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d ); plan = parsePlanFromContent(result.content, { requireIntent: true, locale }); } + const consistencyIssue = !plannerRepairUsed + ? this._plannerIntentConsistencyIssue(plan) + : null; + if (consistencyIssue) { + plannerRepairUsed = true; + consistencyRepairKind = consistencyIssue.kind; + onUpdate('thinking', { step: 0, note: 'Planning… rechecking tool-dependent intent' }); + result = await this._chatWithCostAllowance( + provider, + this._plannerIntentConsistencyRepairMessages(plannerMessages, consistencyIssue), + this._plannerChatOptions(provider, true), + costState, + { tabId, generationName: 'planner' }, + ); + plan = parsePlanFromContent(result.content, { requireIntent: true, locale }); + } // The retry above is a paid LLM call that does not honor the abort flag // itself; re-check before pinning the plan or showing the review card so // a Stop pressed during the retry isn't ignored until after approval. (#2) @@ -7551,6 +7753,11 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d ? this._strictPlannerFailure(onUpdate) : this._plannerReadOnlyFallback(runOptions, onUpdate); } + if (this._plannerIntentUnresolvedConsistencyIssue(plan, consistencyRepairKind)) { + return this._normalizePlanBeforeActMode(plannerMode) === 'strict' + ? this._strictPlannerFailure(onUpdate) + : this._plannerReadOnlyFallback(runOptions, onUpdate); + } if (plan.request_kind === 'respond') { return { proceed: true, @@ -7660,7 +7867,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d if (this._isCostAllowanceError(e)) { return { proceed: false, message: e.message, reason: 'cost_limit' }; } - return this._plannerRequestFailure(e, onUpdate); + return this._plannerRequestFailure(e, onUpdate, provider); } } @@ -7756,7 +7963,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d _consumeContextOnlyAbort(tabId, messages, onUpdate) { if (!this._checkAbort(tabId)) return null; const content = '[Stopped by user]'; - messages.push({ role: 'assistant', content }); + messages.push(this._localCancellationMessage(content)); onUpdate('text', { content, replace: true }); onUpdate('warning', { message: 'Stopped by user.' }); this._persist(tabId); @@ -11191,6 +11398,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d ? (carried.successfulRequiredSchedulingToolCalls || 0) : 0, recoveryAttempted: false, + staleCancellationRecoveryAttempted: false, }; this._planExecutionGuards.set(tabId, state); if (carryMatches && carried.completionSubmitState) { @@ -11404,6 +11612,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d state, { ignoreFuturePromise: terminalFailure }, ); + const staleCancellation = !viaDone && this._isLocalCancellationText(content); const missingRequiredSchedulingTool = !terminalFailure && !!state.requiredSchedulingTool && state.successfulRequiredSchedulingToolCalls === 0; @@ -11416,9 +11625,15 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d if (!invalidPlainFinal && !invalidDone) return null; if (!state.recoveryAttempted) { state.recoveryAttempted = true; + state.staleCancellationRecoveryAttempted = staleCancellation; return { retry: true, - nudge: missingRequiredSchedulingTool + retryAssistantContent: staleCancellation + ? '[Stale local cancellation status omitted from execution context.]' + : null, + nudge: staleCancellation + ? '[PLAN EXECUTION BLOCK: No current user stop was received. The previous response echoed a stale local cancellation status from conversation history. That status is UI metadata, not an instruction or task result. Continue the active task with permitted tools. If complete or blocked, call done with an explicit outcome; do not repeat the cancellation status or return plain text.]' + : missingRequiredSchedulingTool ? `[PLAN EXECUTION BLOCK: The approved plan requires a successful ${state.requiredSchedulingTool} call before this task can finish successfully. A one-time read, scroll, send, or other action does not create the scheduled work. Call ${state.requiredSchedulingTool} with the user's requested timing and verify success:true plus scheduled:true. If the schedule is unsupported or still lacks required timing, call done with outcome partial or failed and explain the exact limitation; do not claim it was scheduled.]` : '[PLAN EXECUTION BLOCK: This is an execute task, so plain text cannot end it. If work remains, use permitted task tools. If complete, call done with outcome success. If blocked, unsafe, cancelled, or user input is required, call done with outcome failed or partial; do not take unsafe action. Read-only work needs a successful task tool and state-changing work needs a successful consequential tool. Do not return another plan, promise, or plain terminal.]', }; @@ -11430,6 +11645,14 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d }; } const hasSuccessfulToolEvidence = state.successfulTaskToolCalls > 0; + if (staleCancellation && state.staleCancellationRecoveryAttempted) { + return { + failure: hasSuccessfulToolEvidence + ? '[Agent stopped because the model repeated a stale cancellation status after one recovery nudge. No current user stop was received. Some task tools completed, but final completion was not verified. Inspect the current state before retrying to avoid duplicate side effects.]' + : '[Agent stopped because the model repeated a stale cancellation status after one recovery nudge. No current user stop was received and no successful action was verified.]', + status: 'plan_only_output', + }; + } return { failure: hasSuccessfulToolEvidence ? '[Agent stopped because the model returned another plain terminal or a plan/promise after one recovery nudge. Some task tools completed, but final completion was not verified. Inspect the current state before retrying to avoid duplicate side effects.]' @@ -11957,8 +12180,18 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d // Strip the scratchpad out of both slices — we re-pin a single copy of // it in the rebuild step below. Without this we'd either lose it (if it // fell into oldMessages and got summarized away) or duplicate it. - const oldMessages = oldMessagesRaw.filter(m => m !== scheduledResumeMsg && !this._isScheduledResumeTurn(m.content) && !this._isPinnedAgentStateMessage(m)); - const recentMessages = recentMessagesRaw.filter(m => m !== scheduledResumeMsg && !this._isScheduledResumeTurn(m.content) && !this._isPinnedAgentStateMessage(m)); + const oldMessages = oldMessagesRaw.filter(m => ( + m !== scheduledResumeMsg + && !this._isScheduledResumeTurn(m.content) + && !this._isPinnedAgentStateMessage(m) + && !this._isLocalConversationStatusMessage(m) + )); + const recentMessages = recentMessagesRaw.filter(m => ( + m !== scheduledResumeMsg + && !this._isScheduledResumeTurn(m.content) + && !this._isPinnedAgentStateMessage(m) + && !this._isLocalConversationStatusMessage(m) + )); // Boundary fix: the recent slice must not begin in the middle of a // tool-call group. If the cutoff lands right after an assistant @@ -12600,7 +12833,9 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d currentUserMessage = null, priorMessageSet = null, ) { - if (runOptions?.sourceGrounding !== SELECTION_ONLY_SOURCE_GROUNDING) return messages; + if (runOptions?.sourceGrounding !== SELECTION_ONLY_SOURCE_GROUNDING) { + return this._modelVisibleConversationMessages(messages); + } const systemMessage = messages[0]?.role === 'system' ? messages[0] : null; const currentTurnIndex = currentUserMessage ? messages.indexOf(currentUserMessage) : -1; @@ -12617,7 +12852,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d const scopedSystemMessage = systemMessage && typeof systemMessage.content === 'string' ? { ...systemMessage, content: `${systemMessage.content}\n\n${SELECTION_SCOPE_SYSTEM_NOTE}` } : systemMessage; - return [ + return this._modelVisibleConversationMessages([ ...(scopedSystemMessage ? [scopedSystemMessage] : []), // Selection shortcuts run in Ask mode and never need durable agent // notes. Exclude them structurally as well as by the persisted prior @@ -12626,7 +12861,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d ...currentRunMessages.filter(message => message !== systemMessage && !this._isPinnedAgentStateMessage(message) ), - ]; + ]); } _emergencyTrimModelCopy(messages) { @@ -13563,6 +13798,10 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d mode: 'act', model: this.providerManager?.getActive?.()?.model || '', providerId: this.providerManager?.activeProviderId || '', + runtimeConfig: this._runtimeTraceConfig(this.providerManager?.getActive?.(), { + tabId, + mode: 'act', + }), }); let traceStatus = 'workflow_stopped'; let finalContent = ''; @@ -19320,7 +19559,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d finalResponse = finalResponse || '[Stopped by user]'; _traceStatus = 'cancelled'; onUpdate('warning', { message: 'Stopped by user.' }); - messages.push({ role: 'assistant', content: finalResponse }); + messages.push(this._localCancellationMessage(finalResponse)); break; } @@ -19466,7 +19705,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d : '[Stopped by user]'; _traceStatus = 'cancelled'; onUpdate('warning', { message: 'Stopped by user.' }); - messages.push({ role: 'assistant', content: finalResponse }); + messages.push(this._localCancellationMessage(finalResponse)); break; } @@ -19640,7 +19879,15 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d } const planOnlyDecision = this._planOnlyTerminalDecision(tabId, result.content); if (planOnlyDecision?.retry) { - messages.push(this._withResponseItems({ role: 'assistant', content: result.content }, result.responseItems, result.reasoningContent, provider)); + messages.push(this._withResponseItems( + { + role: 'assistant', + content: planOnlyDecision.retryAssistantContent ?? result.content, + }, + planOnlyDecision.retryAssistantContent ? null : result.responseItems, + planOnlyDecision.retryAssistantContent ? '' : result.reasoningContent, + provider, + )); messages.push({ role: 'user', content: planOnlyDecision.nudge }); // Clear any already-rendered plan/promise so recovery does not leave // rejected terminal text in the assistant bubble (and so run-complete @@ -19906,8 +20153,11 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d while (steps < this.maxSteps) { if (this._checkAbort(tabId)) { + const content = '[Stopped by user]'; + messages.push(this._localCancellationMessage(content)); + this._persist(tabId); onUpdate('warning', { message: 'Stopped by user.' }); - return finish('[Stopped by user]', 'cancelled'); + return finish(content, 'cancelled'); } if (steps > 0 && !selectionOnly) { @@ -20148,7 +20398,15 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d } const planOnlyDecision = this._planOnlyTerminalDecision(tabId, fullText); if (planOnlyDecision?.retry) { - messages.push(this._withResponseItems({ role: 'assistant', content: fullText }, responseItems, reasoningContent, provider)); + messages.push(this._withResponseItems( + { + role: 'assistant', + content: planOnlyDecision.retryAssistantContent ?? fullText, + }, + planOnlyDecision.retryAssistantContent ? null : responseItems, + planOnlyDecision.retryAssistantContent ? '' : reasoningContent, + provider, + )); messages.push({ role: 'user', content: planOnlyDecision.nudge }); // Streamed plan text already landed via text_delta. Replace it before // the recovery turn so later deltas do not append onto the plan and diff --git a/src/chrome/src/agent/planner.js b/src/chrome/src/agent/planner.js index f759dcd88..b217e45b6 100644 --- a/src/chrome/src/agent/planner.js +++ b/src/chrome/src/agent/planner.js @@ -54,6 +54,8 @@ Rules: - respond when the user asks only for a natural-language answer or recoverable artifact from the existing conversation/working notes and no fresh page read or browser action is needed. - plan_only when the user asks for a plan, outline, strategy, or discussion without authorizing action. - clarify only when missing or conflicting user information prevents a useful plan; make localized.summary the concise question to ask. +- A request to answer, summarize, explain, analyze, or draft a response about currently visible/open page content is execute when producing the answer needs a fresh page or browser read, even if the final deliverable is only text and requires_state_change is false. Example: "How should I respond to this open email?" is execute because the email must be read now; it is not plan_only merely because the deliverable is advice or a draft. +- respond must not include steps that need page, browser, network, memory, or scheduling tools. If any such tool is needed to produce the requested answer, classify the request as execute instead. - requires_state_change is true only when completing an execute request needs a mutation such as interacting with form/account state, modifying page data, downloading/uploading a file, a write-method network request, a Dev patch, or scheduling work. It is false for reads, analysis, summaries, navigation, scrolling, hovering, window/viewport changes, plan_only, and clarify. - requires_submission is true only when completing an execute request requires an explicit form/dialog commit action such as Submit, Save, Send, Publish, Post, or Confirm. It is false for filling, editing, checking, or selecting without committing, including explicit do-not-submit tasks and autosave UIs, and false for non-execute requests. - allows_planner_shaped_result is true only when the user explicitly requests planner-like final data (summary/steps JSON or Plan/Steps/Workflow markdown). Never changes request_kind. @@ -114,6 +116,8 @@ Rules: - respond means the user asks only for a natural-language answer or recoverable artifact from existing conversation/working-note context, with no fresh page read or browser action. - plan_only means the user asks for a plan, outline, strategy, or discussion without authorizing action. - clarify means missing or conflicting user information prevents a useful plan; localized.summary must be the concise question to ask. +- A request to answer, summarize, explain, analyze, or draft a response about currently visible/open page content is execute when producing the answer needs a fresh page or browser read, even if the final deliverable is only text and requires_state_change is false. Example: "How should I respond to this open email?" is execute because the email must be read now. +- respond must not include steps that need page, browser, network, memory, or scheduling tools. If any such tool is needed to produce the requested answer, classify the request as execute instead. - requires_state_change is true only when an execute request needs a mutation such as interacting with form/account state, modifying page data, downloading/uploading a file, a write-method network request, a Dev patch, or scheduling work. It is false for reads, analysis, summaries, navigation, scrolling, hovering, window/viewport changes, plan_only, and clarify. - requires_submission is true only when an execute request must explicitly commit a form/dialog with an action such as Submit, Save, Send, Publish, Post, or Confirm. It is false for filling, editing, checking, or selecting without committing, including explicit do-not-submit tasks and autosave UIs, and false for non-execute requests. - allows_planner_shaped_result is true only when the user explicitly requests planner-like final data (summary/steps JSON or Plan/Steps/Workflow markdown). Never changes request_kind. diff --git a/src/chrome/src/background.js b/src/chrome/src/background.js index 425591c1d..3a03ec72a 100644 --- a/src/chrome/src/background.js +++ b/src/chrome/src/background.js @@ -1380,8 +1380,17 @@ function isClarificationRequiredRunUpdate(update) { && update?.data?.status === 'clarification_required'; } +function isPlannerRequestFailureUpdate(update) { + return update?.type === 'warning' + && update?.data?.code === 'planner_request_failed'; +} + function runUpdatesSucceeded(updates = []) { - return !updates.some(update => update?.type === 'error' || isClarificationRequiredRunUpdate(update)); + return !updates.some(update => ( + update?.type === 'error' + || isClarificationRequiredRunUpdate(update) + || isPlannerRequestFailureUpdate(update) + )); } function terminalRunUiStatus(content, updates = [], error = null) { @@ -1389,7 +1398,7 @@ function terminalRunUiStatus(content, updates = [], error = null) { const text = String(content || ''); if (/stopped by user|aborted by user/i.test(text)) return 'stopped'; if (/before executing requested tool calls/i.test(text)) return 'cancelled'; - if (updates.some(update => update?.type === 'error')) return 'failed'; + if (updates.some(update => update?.type === 'error' || isPlannerRequestFailureUpdate(update))) return 'failed'; if (updates.some(isClarificationRequiredRunUpdate)) return 'clarification_required'; return 'completed'; } diff --git a/src/chrome/src/providers/openai.js b/src/chrome/src/providers/openai.js index c19f23d67..1d640e207 100644 --- a/src/chrome/src/providers/openai.js +++ b/src/chrome/src/providers/openai.js @@ -5,6 +5,7 @@ import { shouldUseOpenAIResponsesApi, supportsOpenAIAskStreaming, } from './provider-compatibility.js'; +import { normalizeRuntimeTraceConfig } from '../trace/runtime-config.js'; const OPENAI_RESPONSES_MIN_MAX_OUTPUT_TOKENS = 16; const KIMI_CURRENT_TOOL_REASONING_MODELS = new Set([ @@ -235,11 +236,16 @@ export class OpenAICompatibleProvider extends BaseLLMProvider { const sessionId = String(options.webbrainSessionId || '').trim(); if (sessionId) body.session_id = sessionId.slice(0, 200); const generationName = String(options.webbrainGenerationName || '').trim().toLowerCase(); - if (generationName) { + const runtimeConfig = normalizeRuntimeTraceConfig(options.webbrainRuntimeConfig); + if (generationName || runtimeConfig) { const trace = body.trace && typeof body.trace === 'object' && !Array.isArray(body.trace) ? body.trace : {}; - body.trace = { ...trace, generation_name: generationName.slice(0, 64) }; + body.trace = { + ...trace, + ...(generationName ? { generation_name: generationName.slice(0, 64) } : {}), + ...(runtimeConfig ? { runtime_config: runtimeConfig } : {}), + }; } } diff --git a/src/chrome/src/trace/recorder.js b/src/chrome/src/trace/recorder.js index 809d3030b..58e7f7abf 100644 --- a/src/chrome/src/trace/recorder.js +++ b/src/chrome/src/trace/recorder.js @@ -1,3 +1,5 @@ +import { normalizeRuntimeTraceConfig } from './runtime-config.js'; + /** * Trace recorder — writes per-run traces (LLM requests/responses, tool calls, * screenshots) into IndexedDB for later inspection and cross-model comparison. @@ -130,6 +132,7 @@ export async function startRun(meta = {}) { providerId: meta.providerId || '', providerClass: meta.providerClass || '', webbrainVersion: meta.webbrainVersion || '', + runtimeConfig: normalizeRuntimeTraceConfig(meta.runtimeConfig), userMessage: meta.userMessage || '', tabUrl: meta.tabUrl || '', tabTitle: meta.tabTitle || '', diff --git a/src/chrome/src/trace/runtime-config.js b/src/chrome/src/trace/runtime-config.js new file mode 100644 index 000000000..5f2eca831 --- /dev/null +++ b/src/chrome/src/trace/runtime-config.js @@ -0,0 +1,59 @@ +const STRING_ENUMS = Object.freeze({ + browser_target: new Set(['chrome', 'firefox']), + mode: new Set(['ask', 'act', 'dev']), + prompt_tier: new Set(['compact', 'mid', 'full']), + plan_before_act_mode: new Set(['off', 'try', 'strict']), + auto_screenshot: new Set(['off', 'navigation', 'state_change', 'every_step']), + image_detail: new Set(['auto', 'low', 'high']), +}); + +const BOOLEAN_FIELDS = Object.freeze([ + 'screenshot_redaction', + 'strict_secret_mode', + 'use_site_adapters', + 'web_mcp_enabled', + 'api_mutations_allowed', + 'user_memory_enabled', + 'selection_grounded', +]); + +const INTEGER_RANGES = Object.freeze({ + max_agent_steps: [0, 10_000], + max_image_dimension: [256, 16_384], + max_screenshots_per_turn: [0, 1_000], +}); + +function safeVersion(value) { + const version = String(value || '').trim(); + return /^[0-9A-Za-z][0-9A-Za-z.+_-]{0,63}$/.test(version) ? version : ''; +} + +/** + * Runtime trace metadata crosses both a provider boundary and an export + * boundary. Keep it to a small, versioned allowlist of booleans, bounded + * integers, and enums so a caller can never smuggle credentials or profile + * contents into a trace by passing an arbitrary settings object. + */ +export function normalizeRuntimeTraceConfig(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + + const normalized = { schema_version: 1 }; + const extensionVersion = safeVersion(value.extension_version); + if (extensionVersion) normalized.extension_version = extensionVersion; + + for (const [field, allowed] of Object.entries(STRING_ENUMS)) { + const candidate = String(value[field] || '').trim().toLowerCase(); + if (allowed.has(candidate)) normalized[field] = candidate; + } + for (const field of BOOLEAN_FIELDS) { + if (typeof value[field] === 'boolean') normalized[field] = value[field]; + } + for (const [field, [min, max]] of Object.entries(INTEGER_RANGES)) { + const candidate = Number(value[field]); + if (Number.isInteger(candidate) && candidate >= min && candidate <= max) { + normalized[field] = candidate; + } + } + + return normalized; +} diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js index 9b0cc91fb..a7e9e36e1 100644 --- a/src/chrome/src/ui/sidepanel.js +++ b/src/chrome/src/ui/sidepanel.js @@ -1703,10 +1703,10 @@ function releaseRetryAttachmentPayload(retryId) { function releaseRetryAttachmentsInTree(root) { if (!root) return; - if (root.matches?.('.error-retry-btn[data-retry-id], .cost-allowance-retry-btn[data-retry-id]')) { + if (root.matches?.('.error-retry-btn[data-retry-id], .cost-allowance-retry-btn[data-retry-id], .planner-request-failure-retry-btn[data-retry-id]')) { releaseRetryAttachmentPayload(root.dataset.retryId); } - root.querySelectorAll?.('.error-retry-btn[data-retry-id], .cost-allowance-retry-btn[data-retry-id]').forEach((btn) => { + root.querySelectorAll?.('.error-retry-btn[data-retry-id], .cost-allowance-retry-btn[data-retry-id], .planner-request-failure-retry-btn[data-retry-id]').forEach((btn) => { releaseRetryAttachmentPayload(btn.dataset.retryId); }); } @@ -3738,6 +3738,14 @@ async function adoptRestoredRunState(tabId, state) { probeFirst: true, requireDurableSubmittedTurn: runUi.kind !== 'continue', }); + const returnedPlannerFailure = plannerRequestFailureUpdate(res?.updates); + if (returnedPlannerFailure && sameTabId(currentTabId, tabId) && !isTabAbortRequested(tabId)) { + renderPlannerRequestFailure( + assistantEl, + returnedPlannerFailure.data, + retryPayloadForRunAssistant(assistantEl), + ); + } const returnedErrorUpdate = Array.isArray(res?.updates) ? res.updates.find(update => update?.type === 'error') : null; @@ -5165,7 +5173,7 @@ function bindErrorRetryButton(btn) { } function rebindRetryButtons() { - document.querySelectorAll('.error-retry-btn').forEach(bindErrorRetryButton); + document.querySelectorAll('.error-retry-btn, .planner-request-failure-retry-btn').forEach(bindErrorRetryButton); } function createActiveChatPayloadState(retryPayload, requestId = '') { @@ -5204,6 +5212,94 @@ function retryPayloadForRunAssistant(assistantEl) { }; } +function plannerRequestFailureUpdate(updates = []) { + if (!Array.isArray(updates)) return null; + return updates.find(update => ( + update?.type === 'warning' + && update?.data?.code === 'planner_request_failed' + )) || null; +} + +function bindPlannerProviderSettingsButton(btn) { + if (!btn || btn.dataset.bound) return; + btn.dataset.bound = 'true'; + btn.addEventListener('click', async (event) => { + event.preventDefault(); + event.stopPropagation(); + btn.disabled = true; + try { + await openProvidersSettingsPage(); + } finally { + btn.disabled = false; + } + }); +} + +function rebindPlannerRequestFailureControls() { + document.querySelectorAll('.planner-request-failure-provider-btn') + .forEach(bindPlannerProviderSettingsButton); +} + +function renderPlannerRequestFailure(assistantEl, data, retryPayload = null) { + if (!assistantEl || data?.code !== 'planner_request_failed') return false; + const textEl = assistantEl.querySelector('.message-text'); + if (!textEl) return false; + if (textEl.querySelector('.planner-request-failure-actions')) return true; + + clearAssistantTextStreamState(assistantEl); + assistantEl.classList.add('planner-request-failure'); + textEl.classList.add('planner-request-failure-content'); + textEl.replaceChildren(); + // Set the role on the empty container first: screen readers announce + // content inserted into an existing live region, not a region that arrives + // already populated. A restored card stays silent, which is what we want. + textEl.setAttribute('role', 'alert'); + + const message = document.createElement('div'); + message.className = 'planner-request-failure-message'; + message.textContent = data.message || 'Planner request failed before a valid response was available.'; + // Name the provider that failed — with several configured, "open Providers" + // is only actionable if the user knows which one to look at. The label is a + // proper noun, so it needs no translation. + if (data.provider) { + const providerName = document.createElement('div'); + providerName.className = 'planner-request-failure-provider'; + providerName.textContent = data.provider; + message.appendChild(providerName); + } + + const actions = document.createElement('div'); + actions.className = 'planner-request-failure-actions'; + + const retryBtn = document.createElement('button'); + retryBtn.type = 'button'; + retryBtn.className = 'planner-request-failure-action planner-request-failure-retry-btn'; + retryBtn.textContent = t('sp.retry'); + retryBtn.title = t('sp.retry'); + retryBtn.setAttribute('aria-label', t('sp.retry')); + const retryReady = configureRetryButton(retryBtn, retryPayload); + + const providerBtn = document.createElement('button'); + providerBtn.type = 'button'; + providerBtn.className = 'planner-request-failure-action planner-request-failure-provider-btn'; + providerBtn.textContent = t('st.tab.providers'); + providerBtn.title = `${t('sp.btn.settings')}: ${t('st.tab.providers')}`; + providerBtn.setAttribute('aria-label', providerBtn.title); + bindPlannerProviderSettingsButton(providerBtn); + + const orderedActions = data.failureKind === 'auth' + ? [providerBtn, retryReady ? retryBtn : null] + : [retryReady ? retryBtn : null, providerBtn]; + const visibleActions = orderedActions.filter(Boolean); + visibleActions[0]?.classList.add('primary'); + visibleActions.forEach(btn => actions.appendChild(btn)); + + textEl.append(message, actions); + addMessageCopyButton(assistantEl); + scrollToBottom(); + return true; +} + function clearActiveChatPayloadForTab(tabId) { if (tabId != null) activeChatPayloadsByTab.delete(tabId); } @@ -5262,6 +5358,7 @@ function rebindRestoredMessageControls() { rebindCopyButtons(); rebindScreenshotSaveButtons(); rebindRetryButtons(); + rebindPlannerRequestFailureControls(); rebindContinueButtons(); rebindClarifyCards(); rebindPlanReviewCards(); @@ -6989,6 +7086,14 @@ async function sendMessage(extraChatParams = {}) { accepted = true; completedSuccessfully = res?.successfulDone === true || updatesContainSuccessfulDone(res?.updates); promptEligibleCompletion = completedSuccessfully || isSuccessfulAskCompletion(modeForSend, res); + const returnedPlannerFailure = plannerRequestFailureUpdate(res?.updates); + if (returnedPlannerFailure + && renderToCurrentTab + && currentTabId === tabId + && !isTabAbortRequested(tabId) + && !clearedConversationRunRequestIds.has(requestId)) { + renderPlannerRequestFailure(assistantEl, returnedPlannerFailure.data, retryPayload); + } const returnedErrorUpdate = Array.isArray(res?.updates) ? res.updates.find(u => u?.type === 'error') : null; @@ -7539,7 +7644,12 @@ function handleAgentUpdateMessage(msg) { case 'warning': hideActivity(); - if (data?.code === 'ask_stream_fallback') { + if (data?.code === 'planner_request_failed') { + const targetAssistantEl = eventAssistantEl || currentAssistantEl; + const retryPayload = activeRetryPayloadForRequest(eventTabId, msg.requestId) + || retryPayloadForRunAssistant(targetAssistantEl); + renderPlannerRequestFailure(targetAssistantEl, data, retryPayload); + } else if (data?.code === 'ask_stream_fallback') { showComposerToast(t('sp.streaming.fallback'), { duration: 6000 }); } break; @@ -8753,7 +8863,8 @@ function configureRetryButton(btn, retryPayload) { } function addErrorRetryButton(msgEl, retryPayload) { - if (!msgEl || !retryPayload?.text || msgEl.querySelector('.error-retry-btn, .cost-allowance-retry-btn')) return; + if (!msgEl || !retryPayload?.text + || msgEl.querySelector('.error-retry-btn, .cost-allowance-retry-btn, .planner-request-failure-retry-btn')) return; msgEl.classList.add('retryable'); const btn = document.createElement('button'); btn.type = 'button'; diff --git a/src/chrome/styles/sidepanel.css b/src/chrome/styles/sidepanel.css index 1e33e8d5b..f772bbf40 100644 --- a/src/chrome/styles/sidepanel.css +++ b/src/chrome/styles/sidepanel.css @@ -2861,6 +2861,79 @@ body { background: var(--accent-hover); } +/* Planner transport/auth failure — actionable assistant card */ +.message.assistant.planner-request-failure .message-content { + background: rgba(244, 67, 54, 0.08); + border-color: rgba(244, 67, 54, 0.3); +} + +.planner-request-failure-content { + display: flex; + flex-direction: column; + gap: 10px; +} + +.planner-request-failure-message { + color: var(--error-soft); +} + +.planner-request-failure-provider { + margin-top: 4px; + font-size: 11px; + font-weight: 600; + opacity: 0.85; +} + +.planner-request-failure-actions { + display: flex; + flex-wrap: wrap; + gap: 7px; +} + +.planner-request-failure-action { + min-height: 30px; + padding: 5px 11px; + border: 1px solid rgba(244, 67, 54, 0.35); + border-radius: 6px; + background: rgba(244, 67, 54, 0.06); + color: var(--error-soft); + font: inherit; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s; +} + +.planner-request-failure-action.primary { + border-color: var(--accent); + background: var(--accent); + color: #fff; +} + +.planner-request-failure-action:hover:not(:disabled), +.planner-request-failure-action:focus-visible { + border-color: rgba(244, 67, 54, 0.6); + background: rgba(244, 67, 54, 0.16); + color: var(--text-primary); +} + +.planner-request-failure-action.primary:hover:not(:disabled), +.planner-request-failure-action.primary:focus-visible { + border-color: var(--accent-hover); + background: var(--accent-hover); + color: #fff; +} + +.planner-request-failure-action:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.planner-request-failure-action:disabled { + cursor: wait; + opacity: 0.65; +} + /* Error messages */ .message.error .message-content { background: rgba(244, 67, 54, 0.1); diff --git a/src/firefox/src/agent/agent.js b/src/firefox/src/agent/agent.js index 32f5b7068..c55b7865e 100644 --- a/src/firefox/src/agent/agent.js +++ b/src/firefox/src/agent/agent.js @@ -33,6 +33,7 @@ import { PDF_PASSTHROUGH_MAX_BYTES, } from './pdf-tools.js'; import * as trace from '../trace/recorder.js'; +import { normalizeRuntimeTraceConfig } from '../trace/runtime-config.js'; import { tracesToMarkdown } from './trace-export.js'; import { solveCaptcha, detectCaptcha, injectToken, captchaParamError, captchaTypesMatch, captchaWebsiteUrl } from './captcha-solver.js'; import { isCapsolverEnabled, normalizeCapsolverApiKey } from './capsolver-config.js'; @@ -86,6 +87,7 @@ const TOKENS_PER_MILLION = 1_000_000; const DEFAULT_INPUT_COST_PER_MILLION_USD = 3; const DEFAULT_OUTPUT_COST_PER_MILLION_USD = 15; const DONE_OUTCOMES = new Set(['success', 'partial', 'failed']); +const LOCAL_CANCELLATION_ASSISTANT_RE = /^\[?Stopped by user(?: before (?:the run started|executing requested tool calls))?\.?\]?$/; // Appended to the system prompt of every selection-grounded model request. // The scope hides the page and disables tools, so the model must explain the // boundary instead of guessing when a follow-up reaches beyond the selection. @@ -120,6 +122,41 @@ function normalizeDoneOutcome(value) { return DONE_OUTCOMES.has(outcome) ? outcome : null; } +// Only read a 3-digit number as a status code where it actually reads like +// one — prefixed by a status word, leading the message, or parenthesized. +// A bare scan would turn "max_tokens 512" into a 5xx and recommend a retry +// that can only fail the same way. +const PLANNER_FAILURE_STATUS_PATTERNS = [ + /\b(?:error|http|status|code|returned|response)\s*[:#-]?\s*(\d{3})\b/i, + /^\s*\(?(\d{3})\)?\b/, + /\((\d{3})\)/, +]; +const PLANNER_AUTH_FAILURE_RE = /\b(?:unauthori[sz]ed|unauthenticated|forbidden|permission denied|invalid credentials|(?:invalid|incorrect|missing|expired)[\s_-]*api[\s_-]*key|api[\s_-]*key[\s_-]*(?:is\s*)?(?:missing|invalid|incorrect|expired|not\s*set|required)|authentication|(?:expired|invalid)[\s_-]*token|token[\s_-]*expired)\b/i; +// "Failed to fetch" (Chrome) and "NetworkError…" (Firefox) are the shapes a +// dead local provider or an offline machine actually produces, so they have +// to land in the bucket that offers Retry first. +const PLANNER_TRANSIENT_FAILURE_RE = /\b(?:timed?\s*out|timeout|network\w*|failed to fetch|fetch failed|load failed|connection (?:closed|reset|refused|error)|econn\w+|socket hang up|temporar(?:y|ily)|unavailable|overloaded|rate[\s_-]?limit\w*|too many requests|bad gateway|gateway timeout)\b/i; + +function plannerRequestFailureKind(detail) { + const text = String(detail || ''); + let status = 0; + for (const pattern of PLANNER_FAILURE_STATUS_PATTERNS) { + const match = text.match(pattern); + if (match) { + status = Number(match[1]); + break; + } + } + if (status === 401 || status === 403) return 'auth'; + if ([408, 425, 429].includes(status) || (status >= 500 && status <= 599)) return 'transient'; + if (PLANNER_AUTH_FAILURE_RE.test(text)) return 'auth'; + // A remaining 4xx is a request the provider rejected on its merits; a retry + // of the same request is not the fix. + if (status >= 400 && status <= 499) return 'provider'; + if (PLANNER_TRANSIENT_FAILURE_RE.test(text)) return 'transient'; + return 'provider'; +} + /** * The WebBrain Agent — orchestrates multi-step LLM + tool-use loops. */ @@ -823,6 +860,34 @@ export class Agent extends LoopDetector { return this.conversationIds.get(tabId) || null; } + _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'); + return normalizeRuntimeTraceConfig({ + extension_version: extensionVersion, + browser_target: 'firefox', + mode: effectiveMode, + prompt_tier: promptTier, + 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), + image_detail: this.imageDetail, + max_agent_steps: this.maxSteps, + max_image_dimension: this.maxImageDimension, + max_screenshots_per_turn: this.maxScreenshotsPerTurn, + }); + } + _cloudGenerationOptions(provider, options = {}, { tabId = null, conversationId = null, generationName = 'main' } = {}) { if (String(provider?.config?.providerName || '').toLowerCase() !== 'webbrain-cloud') return options; const effectiveConversationId = conversationId || (tabId != null ? this.conversationIds.get(tabId) : null); @@ -831,6 +896,7 @@ export class Agent extends LoopDetector { ...options, webbrainSessionId: String(effectiveConversationId), webbrainGenerationName: String(generationName || 'main'), + webbrainRuntimeConfig: this._runtimeTraceConfig(provider, { tabId }), }; } @@ -5693,6 +5759,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d providerId: provider?.name, providerClass: provider?.constructor?.name, webbrainVersion: browser.runtime.getManifest().version || '', + runtimeConfig: this._runtimeTraceConfig(provider, { tabId, mode }), userMessage: typeof userMessage === 'string' ? userMessage : JSON.stringify(userMessage).slice(0, 2000), tabUrl, tabTitle, @@ -5730,12 +5797,37 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d * planner can resolve follow-up references ("continue", "open the first * result"). Skips system / scratchpad / progress-ledger bookkeeping turns. */ + _isLocalCancellationText(content) { + return typeof content === 'string' + && LOCAL_CANCELLATION_ASSISTANT_RE.test(content.trim()); + } + + _localCancellationMessage(content) { + return { + role: 'assistant', + content, + webbrainLocalStatus: 'cancelled', + }; + } + + _isLocalConversationStatusMessage(message) { + if (message?.role !== 'assistant') return false; + return message.webbrainLocalStatus === 'cancelled' + || this._isLocalCancellationText(message.content); + } + + _modelVisibleConversationMessages(messages) { + if (!Array.isArray(messages)) return []; + return messages.filter(message => !this._isLocalConversationStatusMessage(message)); + } + _buildPlannerHistoryDigest(messages, maxChars = 1500) { if (!Array.isArray(messages) || messages.length === 0) return ''; const lines = []; for (const m of messages.slice(-10)) { if (!m || (m.role !== 'user' && m.role !== 'assistant')) continue; if (this._isPinnedAgentStateMessage(m)) continue; + if (this._isLocalConversationStatusMessage(m)) continue; const rawText = userMessageToText(m); const taskText = m.role === 'user' ? this._stripInjectedTaskContext(rawText) : rawText; const text = sanitizePlannerText(taskText, 300, { collapseWhitespace: true }); @@ -6102,6 +6194,9 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d reason: gate.reason, requestKind: gate.requestKind, requiresStateChange: gate.requiresStateChange, + // Carried so a caller can tell an auth failure from a transient one + // without re-parsing the message text. + ...(gate.failureKind ? { failureKind: gate.failureKind } : {}), }; } @@ -6176,6 +6271,60 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d ]; } + _plannerIntentConsistencyIssue(plan) { + if (!plan || !Array.isArray(plan.steps)) return null; + const tools = [...new Set( + plan.steps.flatMap(step => Array.isArray(step?.tools) ? step.tools : []) + .map(tool => String(tool || '').trim()) + .filter(Boolean) + )]; + if (plan.request_kind === 'respond' && tools.length > 0) { + return { kind: 'respond_with_tools', tools }; + } + const executionTools = tools.filter(tool => tool !== 'done'); + if (plan.request_kind === 'plan_only' && executionTools.length > 0) { + return { kind: 'plan_only_with_execution_tools', tools: executionTools }; + } + return null; + } + + _plannerIntentConsistencyRepairMessages(plannerMessages, issue) { + const issueKind = issue?.kind === 'respond_with_tools' + || issue?.kind === 'plan_only_with_execution_tools' + ? issue.kind + : 'unknown'; + return [ + ...plannerMessages, + { + role: 'user', + content: + '/no_think\n' + + `The previous JSON was parseable but its intent needs one consistency check (${issueKind}). ` + + 'Re-read only the authoritative User task above and output one corrected JSON object. ' + + 'Keep plan_only only when the user asked merely for a plan, outline, strategy, or discussion without authorizing execution. ' + + 'Use execute when producing the requested answer needs a fresh page, browser, network, memory, or scheduling tool; read-only execution still has requires_state_change false. ' + + 'Use respond only when existing conversation or working-note context is sufficient and list no tools. ' + + 'Do not infer permission for a mutation that the current user task did not authorize. No prose, markdown, tool calls, or reasoning text.', + }, + ]; + } + + _plannerIntentUnresolvedConsistencyIssue(plan, recheckedIssueKind = null) { + const issue = this._plannerIntentConsistencyIssue(plan); + if (!issue) return null; + // Repeating plan_only after the focused semantic recheck confirms that the + // user asked only for a plan. A respond plan that still lists tools can + // never be routed response-only, and a new issue after either repair has + // not received the focused consistency check. + if ( + issue.kind === 'plan_only_with_execution_tools' + && recheckedIssueKind === issue.kind + ) { + return null; + } + return issue; + } + _plannerIntentFailureMessage(runOptions = {}) { return sanitizePlannerText( runOptions?.intentFailureMessage @@ -6220,20 +6369,32 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d }; } - _plannerRequestFailure(error, onUpdate) { + _plannerRequestFailure(error, onUpdate, provider = null) { const detail = sanitizePlannerText( error?.message || String(error || 'Unknown planner request error.'), 500, { collapseWhitespace: true }, ); - const message = `Planner request failed before a valid response was available: ${detail}`; - onUpdate('warning', { message }); + const detailSentence = /[.!?]$/.test(detail) ? detail : `${detail}.`; + const message = `Planner request failed before a valid response was available: ${detailSentence} No tools ran.`; + const failureKind = plannerRequestFailureKind(detail); + onUpdate('warning', { + code: 'planner_request_failed', + message, + failureKind, + provider: sanitizePlannerText( + provider?.config?.label || provider?.name || provider?.config?.providerName || '', + 80, + { collapseWhitespace: true }, + ), + }); return { proceed: false, message, reason: 'planner_error', requestKind: 'respond', requiresStateChange: false, + failureKind, }; } @@ -6350,8 +6511,11 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d } if (this._checkAbort(tabId)) return { proceed: false, message: '[Stopped by user]', reason: 'cancelled' }; + let plannerRepairUsed = false; + let consistencyRepairKind = null; let plan = parsePlanFromContent(result.content, { requireIntent: true, locale }); if (!plan) { + plannerRepairUsed = true; onUpdate('thinking', { step: plannerStep, note: 'Understanding request… retrying JSON output' }); result = await this._chatWithCostAllowance( provider, @@ -6362,10 +6526,29 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d ); plan = parsePlanFromContent(result.content, { requireIntent: true, locale }); } + const consistencyIssue = !plannerRepairUsed + ? this._plannerIntentConsistencyIssue(plan) + : null; + if (consistencyIssue) { + plannerRepairUsed = true; + consistencyRepairKind = consistencyIssue.kind; + onUpdate('thinking', { step: plannerStep, note: 'Understanding request… rechecking tool-dependent intent' }); + result = await this._chatWithCostAllowance( + provider, + this._plannerIntentConsistencyRepairMessages(plannerMessages, consistencyIssue), + this._plannerChatOptions(provider, true, true), + costState, + { tabId, generationName: 'planner_intent' }, + ); + plan = parsePlanFromContent(result.content, { requireIntent: true, locale }); + } if (this._checkAbort(tabId)) return { proceed: false, message: '[Stopped by user]', reason: 'cancelled' }; if (!plan) { return this._plannerReadOnlyFallback(runOptions, onUpdate); } + if (this._plannerIntentUnresolvedConsistencyIssue(plan, consistencyRepairKind)) { + return this._plannerReadOnlyFallback(runOptions, onUpdate); + } if (plan.request_kind === 'respond') { return { proceed: true, @@ -6400,7 +6583,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d if (this._isCostAllowanceError(e)) { return { proceed: false, message: e.message, reason: 'cost_limit' }; } - return this._plannerRequestFailure(e, onUpdate); + return this._plannerRequestFailure(e, onUpdate, provider); } } @@ -6458,12 +6641,15 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d if (this._checkAbort(tabId)) { return { proceed: false, message: '[Stopped by user]' }; } + let plannerRepairUsed = false; + let consistencyRepairKind = null; let plan = parsePlanFromContent(result.content, { requireIntent: true, locale }); // Retry whenever the first attempt yields no parseable plan — empty // output, thinking-only output, OR non-JSON prose ("Sure, here's the // plan…"). The repair prompt exists precisely to coerce JSON out of that // prose case, so it must not be gated on emptiness/reasoning. (#1) if (!plan) { + plannerRepairUsed = true; onUpdate('thinking', { step: 0, note: 'Planning… retrying JSON output' }); result = await this._chatWithCostAllowance( provider, @@ -6474,6 +6660,22 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d ); plan = parsePlanFromContent(result.content, { requireIntent: true, locale }); } + const consistencyIssue = !plannerRepairUsed + ? this._plannerIntentConsistencyIssue(plan) + : null; + if (consistencyIssue) { + plannerRepairUsed = true; + consistencyRepairKind = consistencyIssue.kind; + onUpdate('thinking', { step: 0, note: 'Planning… rechecking tool-dependent intent' }); + result = await this._chatWithCostAllowance( + provider, + this._plannerIntentConsistencyRepairMessages(plannerMessages, consistencyIssue), + this._plannerChatOptions(provider, true), + costState, + { tabId, generationName: 'planner' }, + ); + plan = parsePlanFromContent(result.content, { requireIntent: true, locale }); + } // The retry above is a paid LLM call that does not honor the abort flag // itself; re-check before pinning the plan or showing the review card so // a Stop pressed during the retry isn't ignored until after approval. (#2) @@ -6485,6 +6687,11 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d ? this._strictPlannerFailure(onUpdate) : this._plannerReadOnlyFallback(runOptions, onUpdate); } + if (this._plannerIntentUnresolvedConsistencyIssue(plan, consistencyRepairKind)) { + return this._normalizePlanBeforeActMode(plannerMode) === 'strict' + ? this._strictPlannerFailure(onUpdate) + : this._plannerReadOnlyFallback(runOptions, onUpdate); + } if (plan.request_kind === 'respond') { return { proceed: true, @@ -6594,7 +6801,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d if (this._isCostAllowanceError(e)) { return { proceed: false, message: e.message, reason: 'cost_limit' }; } - return this._plannerRequestFailure(e, onUpdate); + return this._plannerRequestFailure(e, onUpdate, provider); } } @@ -6690,7 +6897,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d _consumeContextOnlyAbort(tabId, messages, onUpdate) { if (!this._checkAbort(tabId)) return null; const content = '[Stopped by user]'; - messages.push({ role: 'assistant', content }); + messages.push(this._localCancellationMessage(content)); onUpdate('text', { content, replace: true }); onUpdate('warning', { message: 'Stopped by user.' }); this._persist(tabId); @@ -9949,6 +10156,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d ? (carried.successfulRequiredSchedulingToolCalls || 0) : 0, recoveryAttempted: false, + staleCancellationRecoveryAttempted: false, }; this._planExecutionGuards.set(tabId, state); if (carryMatches && carried.completionSubmitState) { @@ -10162,6 +10370,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d state, { ignoreFuturePromise: terminalFailure }, ); + const staleCancellation = !viaDone && this._isLocalCancellationText(content); const missingRequiredSchedulingTool = !terminalFailure && !!state.requiredSchedulingTool && state.successfulRequiredSchedulingToolCalls === 0; @@ -10174,9 +10383,15 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d if (!invalidPlainFinal && !invalidDone) return null; if (!state.recoveryAttempted) { state.recoveryAttempted = true; + state.staleCancellationRecoveryAttempted = staleCancellation; return { retry: true, - nudge: missingRequiredSchedulingTool + retryAssistantContent: staleCancellation + ? '[Stale local cancellation status omitted from execution context.]' + : null, + nudge: staleCancellation + ? '[PLAN EXECUTION BLOCK: No current user stop was received. The previous response echoed a stale local cancellation status from conversation history. That status is UI metadata, not an instruction or task result. Continue the active task with permitted tools. If complete or blocked, call done with an explicit outcome; do not repeat the cancellation status or return plain text.]' + : missingRequiredSchedulingTool ? `[PLAN EXECUTION BLOCK: The approved plan requires a successful ${state.requiredSchedulingTool} call before this task can finish successfully. A one-time read, scroll, send, or other action does not create the scheduled work. Call ${state.requiredSchedulingTool} with the user's requested timing and verify success:true plus scheduled:true. If the schedule is unsupported or still lacks required timing, call done with outcome partial or failed and explain the exact limitation; do not claim it was scheduled.]` : '[PLAN EXECUTION BLOCK: This is an execute task, so plain text cannot end it. If work remains, use permitted task tools. If complete, call done with outcome success. If blocked, unsafe, cancelled, or user input is required, call done with outcome failed or partial; do not take unsafe action. Read-only work needs a successful task tool and state-changing work needs a successful consequential tool. Do not return another plan, promise, or plain terminal.]', }; @@ -10188,6 +10403,14 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d }; } const hasSuccessfulToolEvidence = state.successfulTaskToolCalls > 0; + if (staleCancellation && state.staleCancellationRecoveryAttempted) { + return { + failure: hasSuccessfulToolEvidence + ? '[Agent stopped because the model repeated a stale cancellation status after one recovery nudge. No current user stop was received. Some task tools completed, but final completion was not verified. Inspect the current state before retrying to avoid duplicate side effects.]' + : '[Agent stopped because the model repeated a stale cancellation status after one recovery nudge. No current user stop was received and no successful action was verified.]', + status: 'plan_only_output', + }; + } return { failure: hasSuccessfulToolEvidence ? '[Agent stopped because the model returned another plain terminal or a plan/promise after one recovery nudge. Some task tools completed, but final completion was not verified. Inspect the current state before retrying to avoid duplicate side effects.]' @@ -10697,8 +10920,18 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d const recentStart = Math.max(afterPin, messages.length - keepRecent); const oldMessagesRaw = messages.slice(afterPin, recentStart); const recentMessagesRaw = messages.slice(recentStart); - const oldMessages = oldMessagesRaw.filter(m => m !== scheduledResumeMsg && !this._isScheduledResumeTurn(m.content) && !this._isPinnedAgentStateMessage(m)); - const recentMessages = recentMessagesRaw.filter(m => m !== scheduledResumeMsg && !this._isScheduledResumeTurn(m.content) && !this._isPinnedAgentStateMessage(m)); + const oldMessages = oldMessagesRaw.filter(m => ( + m !== scheduledResumeMsg + && !this._isScheduledResumeTurn(m.content) + && !this._isPinnedAgentStateMessage(m) + && !this._isLocalConversationStatusMessage(m) + )); + const recentMessages = recentMessagesRaw.filter(m => ( + m !== scheduledResumeMsg + && !this._isScheduledResumeTurn(m.content) + && !this._isPinnedAgentStateMessage(m) + && !this._isLocalConversationStatusMessage(m) + )); // Boundary fix: the recent slice must not begin in the middle of a // tool-call group. If the cutoff lands right after an assistant @@ -11337,7 +11570,9 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d currentUserMessage = null, priorMessageSet = null, ) { - if (runOptions?.sourceGrounding !== SELECTION_ONLY_SOURCE_GROUNDING) return messages; + if (runOptions?.sourceGrounding !== SELECTION_ONLY_SOURCE_GROUNDING) { + return this._modelVisibleConversationMessages(messages); + } const systemMessage = messages[0]?.role === 'system' ? messages[0] : null; const currentTurnIndex = currentUserMessage ? messages.indexOf(currentUserMessage) : -1; @@ -11354,7 +11589,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d const scopedSystemMessage = systemMessage && typeof systemMessage.content === 'string' ? { ...systemMessage, content: `${systemMessage.content}\n\n${SELECTION_SCOPE_SYSTEM_NOTE}` } : systemMessage; - return [ + return this._modelVisibleConversationMessages([ ...(scopedSystemMessage ? [scopedSystemMessage] : []), // Selection shortcuts run in Ask mode and never need durable agent // notes. Exclude them structurally as well as by the persisted prior @@ -11363,7 +11598,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d ...currentRunMessages.filter(message => message !== systemMessage && !this._isPinnedAgentStateMessage(message) ), - ]; + ]); } _emergencyTrimModelCopy(messages) { @@ -11833,6 +12068,10 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d mode: 'act', model: this.providerManager?.getActive?.()?.model || '', providerId: this.providerManager?.activeProviderId || '', + runtimeConfig: this._runtimeTraceConfig(this.providerManager?.getActive?.(), { + tabId, + mode: 'act', + }), }); let traceStatus = 'workflow_stopped'; let finalContent = ''; @@ -14492,7 +14731,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d finalResponse = finalResponse || '[Stopped by user]'; _traceStatus = 'cancelled'; onUpdate('warning', { message: 'Stopped by user.' }); - messages.push({ role: 'assistant', content: finalResponse }); + messages.push(this._localCancellationMessage(finalResponse)); break; } @@ -14637,7 +14876,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d : '[Stopped by user]'; _traceStatus = 'cancelled'; onUpdate('warning', { message: 'Stopped by user.' }); - messages.push({ role: 'assistant', content: finalResponse }); + messages.push(this._localCancellationMessage(finalResponse)); break; } @@ -14803,7 +15042,15 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d } const planOnlyDecision = this._planOnlyTerminalDecision(tabId, result.content); if (planOnlyDecision?.retry) { - messages.push(this._withResponseItems({ role: 'assistant', content: result.content }, result.responseItems, result.reasoningContent, provider)); + messages.push(this._withResponseItems( + { + role: 'assistant', + content: planOnlyDecision.retryAssistantContent ?? result.content, + }, + planOnlyDecision.retryAssistantContent ? null : result.responseItems, + planOnlyDecision.retryAssistantContent ? '' : result.reasoningContent, + provider, + )); messages.push({ role: 'user', content: planOnlyDecision.nudge }); // Clear any already-rendered plan/promise so recovery does not leave // rejected terminal text in the assistant bubble (and so run-complete @@ -15056,8 +15303,11 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d while (steps < this.maxSteps) { if (this._checkAbort(tabId)) { + const content = '[Stopped by user]'; + messages.push(this._localCancellationMessage(content)); + this._persist(tabId); onUpdate('warning', { message: 'Stopped by user.' }); - return finish('[Stopped by user]', 'cancelled'); + return finish(content, 'cancelled'); } if (steps > 0 && !selectionOnly) { @@ -15295,7 +15545,15 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d } const planOnlyDecision = this._planOnlyTerminalDecision(tabId, fullText); if (planOnlyDecision?.retry) { - messages.push(this._withResponseItems({ role: 'assistant', content: fullText }, responseItems, reasoningContent, provider)); + messages.push(this._withResponseItems( + { + role: 'assistant', + content: planOnlyDecision.retryAssistantContent ?? fullText, + }, + planOnlyDecision.retryAssistantContent ? null : responseItems, + planOnlyDecision.retryAssistantContent ? '' : reasoningContent, + provider, + )); messages.push({ role: 'user', content: planOnlyDecision.nudge }); // Streamed plan text already landed via text_delta. Replace it before // the recovery turn so later deltas do not append onto the plan and diff --git a/src/firefox/src/agent/planner.js b/src/firefox/src/agent/planner.js index f759dcd88..b217e45b6 100644 --- a/src/firefox/src/agent/planner.js +++ b/src/firefox/src/agent/planner.js @@ -54,6 +54,8 @@ Rules: - respond when the user asks only for a natural-language answer or recoverable artifact from the existing conversation/working notes and no fresh page read or browser action is needed. - plan_only when the user asks for a plan, outline, strategy, or discussion without authorizing action. - clarify only when missing or conflicting user information prevents a useful plan; make localized.summary the concise question to ask. +- A request to answer, summarize, explain, analyze, or draft a response about currently visible/open page content is execute when producing the answer needs a fresh page or browser read, even if the final deliverable is only text and requires_state_change is false. Example: "How should I respond to this open email?" is execute because the email must be read now; it is not plan_only merely because the deliverable is advice or a draft. +- respond must not include steps that need page, browser, network, memory, or scheduling tools. If any such tool is needed to produce the requested answer, classify the request as execute instead. - requires_state_change is true only when completing an execute request needs a mutation such as interacting with form/account state, modifying page data, downloading/uploading a file, a write-method network request, a Dev patch, or scheduling work. It is false for reads, analysis, summaries, navigation, scrolling, hovering, window/viewport changes, plan_only, and clarify. - requires_submission is true only when completing an execute request requires an explicit form/dialog commit action such as Submit, Save, Send, Publish, Post, or Confirm. It is false for filling, editing, checking, or selecting without committing, including explicit do-not-submit tasks and autosave UIs, and false for non-execute requests. - allows_planner_shaped_result is true only when the user explicitly requests planner-like final data (summary/steps JSON or Plan/Steps/Workflow markdown). Never changes request_kind. @@ -114,6 +116,8 @@ Rules: - respond means the user asks only for a natural-language answer or recoverable artifact from existing conversation/working-note context, with no fresh page read or browser action. - plan_only means the user asks for a plan, outline, strategy, or discussion without authorizing action. - clarify means missing or conflicting user information prevents a useful plan; localized.summary must be the concise question to ask. +- A request to answer, summarize, explain, analyze, or draft a response about currently visible/open page content is execute when producing the answer needs a fresh page or browser read, even if the final deliverable is only text and requires_state_change is false. Example: "How should I respond to this open email?" is execute because the email must be read now. +- respond must not include steps that need page, browser, network, memory, or scheduling tools. If any such tool is needed to produce the requested answer, classify the request as execute instead. - requires_state_change is true only when an execute request needs a mutation such as interacting with form/account state, modifying page data, downloading/uploading a file, a write-method network request, a Dev patch, or scheduling work. It is false for reads, analysis, summaries, navigation, scrolling, hovering, window/viewport changes, plan_only, and clarify. - requires_submission is true only when an execute request must explicitly commit a form/dialog with an action such as Submit, Save, Send, Publish, Post, or Confirm. It is false for filling, editing, checking, or selecting without committing, including explicit do-not-submit tasks and autosave UIs, and false for non-execute requests. - allows_planner_shaped_result is true only when the user explicitly requests planner-like final data (summary/steps JSON or Plan/Steps/Workflow markdown). Never changes request_kind. diff --git a/src/firefox/src/background.js b/src/firefox/src/background.js index dd1b3d9df..60e593d99 100644 --- a/src/firefox/src/background.js +++ b/src/firefox/src/background.js @@ -1472,8 +1472,17 @@ function isClarificationRequiredRunUpdate(update) { && update?.data?.status === 'clarification_required'; } +function isPlannerRequestFailureUpdate(update) { + return update?.type === 'warning' + && update?.data?.code === 'planner_request_failed'; +} + function runUpdatesSucceeded(updates = []) { - return !updates.some(update => update?.type === 'error' || isClarificationRequiredRunUpdate(update)); + return !updates.some(update => ( + update?.type === 'error' + || isClarificationRequiredRunUpdate(update) + || isPlannerRequestFailureUpdate(update) + )); } function terminalRunUiStatus(content, updates = [], error = null) { @@ -1481,7 +1490,7 @@ function terminalRunUiStatus(content, updates = [], error = null) { const text = String(content || ''); if (/stopped by user|aborted by user/i.test(text)) return 'stopped'; if (/before executing requested tool calls/i.test(text)) return 'cancelled'; - if (updates.some(update => update?.type === 'error')) return 'failed'; + if (updates.some(update => update?.type === 'error' || isPlannerRequestFailureUpdate(update))) return 'failed'; if (updates.some(isClarificationRequiredRunUpdate)) return 'clarification_required'; return 'completed'; } diff --git a/src/firefox/src/providers/openai.js b/src/firefox/src/providers/openai.js index 869b28a07..ba26e4aa8 100644 --- a/src/firefox/src/providers/openai.js +++ b/src/firefox/src/providers/openai.js @@ -5,6 +5,7 @@ import { shouldUseOpenAIResponsesApi, supportsOpenAIAskStreaming, } from './provider-compatibility.js'; +import { normalizeRuntimeTraceConfig } from '../trace/runtime-config.js'; const OPENAI_RESPONSES_MIN_MAX_OUTPUT_TOKENS = 16; const KIMI_CURRENT_TOOL_REASONING_MODELS = new Set([ @@ -235,11 +236,16 @@ export class OpenAICompatibleProvider extends BaseLLMProvider { const sessionId = String(options.webbrainSessionId || '').trim(); if (sessionId) body.session_id = sessionId.slice(0, 200); const generationName = String(options.webbrainGenerationName || '').trim().toLowerCase(); - if (generationName) { + const runtimeConfig = normalizeRuntimeTraceConfig(options.webbrainRuntimeConfig); + if (generationName || runtimeConfig) { const trace = body.trace && typeof body.trace === 'object' && !Array.isArray(body.trace) ? body.trace : {}; - body.trace = { ...trace, generation_name: generationName.slice(0, 64) }; + body.trace = { + ...trace, + ...(generationName ? { generation_name: generationName.slice(0, 64) } : {}), + ...(runtimeConfig ? { runtime_config: runtimeConfig } : {}), + }; } } diff --git a/src/firefox/src/trace/recorder.js b/src/firefox/src/trace/recorder.js index 91ff616db..18dd6a439 100644 --- a/src/firefox/src/trace/recorder.js +++ b/src/firefox/src/trace/recorder.js @@ -1,3 +1,5 @@ +import { normalizeRuntimeTraceConfig } from './runtime-config.js'; + /** * Trace recorder — writes per-run traces (LLM requests/responses, tool calls, * screenshots) into IndexedDB for later inspection and cross-model comparison. @@ -114,6 +116,7 @@ export async function startRun(meta) { providerId: meta.providerId || '', providerClass: meta.providerClass || '', webbrainVersion: meta.webbrainVersion || '', + runtimeConfig: normalizeRuntimeTraceConfig(meta.runtimeConfig), userMessage: meta.userMessage || '', tabUrl: meta.tabUrl || '', tabTitle: meta.tabTitle || '', diff --git a/src/firefox/src/trace/runtime-config.js b/src/firefox/src/trace/runtime-config.js new file mode 100644 index 000000000..5f2eca831 --- /dev/null +++ b/src/firefox/src/trace/runtime-config.js @@ -0,0 +1,59 @@ +const STRING_ENUMS = Object.freeze({ + browser_target: new Set(['chrome', 'firefox']), + mode: new Set(['ask', 'act', 'dev']), + prompt_tier: new Set(['compact', 'mid', 'full']), + plan_before_act_mode: new Set(['off', 'try', 'strict']), + auto_screenshot: new Set(['off', 'navigation', 'state_change', 'every_step']), + image_detail: new Set(['auto', 'low', 'high']), +}); + +const BOOLEAN_FIELDS = Object.freeze([ + 'screenshot_redaction', + 'strict_secret_mode', + 'use_site_adapters', + 'web_mcp_enabled', + 'api_mutations_allowed', + 'user_memory_enabled', + 'selection_grounded', +]); + +const INTEGER_RANGES = Object.freeze({ + max_agent_steps: [0, 10_000], + max_image_dimension: [256, 16_384], + max_screenshots_per_turn: [0, 1_000], +}); + +function safeVersion(value) { + const version = String(value || '').trim(); + return /^[0-9A-Za-z][0-9A-Za-z.+_-]{0,63}$/.test(version) ? version : ''; +} + +/** + * Runtime trace metadata crosses both a provider boundary and an export + * boundary. Keep it to a small, versioned allowlist of booleans, bounded + * integers, and enums so a caller can never smuggle credentials or profile + * contents into a trace by passing an arbitrary settings object. + */ +export function normalizeRuntimeTraceConfig(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + + const normalized = { schema_version: 1 }; + const extensionVersion = safeVersion(value.extension_version); + if (extensionVersion) normalized.extension_version = extensionVersion; + + for (const [field, allowed] of Object.entries(STRING_ENUMS)) { + const candidate = String(value[field] || '').trim().toLowerCase(); + if (allowed.has(candidate)) normalized[field] = candidate; + } + for (const field of BOOLEAN_FIELDS) { + if (typeof value[field] === 'boolean') normalized[field] = value[field]; + } + for (const [field, [min, max]] of Object.entries(INTEGER_RANGES)) { + const candidate = Number(value[field]); + if (Number.isInteger(candidate) && candidate >= min && candidate <= max) { + normalized[field] = candidate; + } + } + + return normalized; +} diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index d42332331..6e22b8c03 100644 --- a/src/firefox/src/ui/sidepanel.js +++ b/src/firefox/src/ui/sidepanel.js @@ -1793,10 +1793,10 @@ function releaseRetryAttachmentPayload(retryId) { function releaseRetryAttachmentsInTree(root) { if (!root) return; - if (root.matches?.('.error-retry-btn[data-retry-id], .cost-allowance-retry-btn[data-retry-id]')) { + if (root.matches?.('.error-retry-btn[data-retry-id], .cost-allowance-retry-btn[data-retry-id], .planner-request-failure-retry-btn[data-retry-id]')) { releaseRetryAttachmentPayload(root.dataset.retryId); } - root.querySelectorAll?.('.error-retry-btn[data-retry-id], .cost-allowance-retry-btn[data-retry-id]').forEach((btn) => { + root.querySelectorAll?.('.error-retry-btn[data-retry-id], .cost-allowance-retry-btn[data-retry-id], .planner-request-failure-retry-btn[data-retry-id]').forEach((btn) => { releaseRetryAttachmentPayload(btn.dataset.retryId); }); } @@ -3587,6 +3587,14 @@ async function adoptRestoredRunState(tabId, state) { probeFirst: true, requireDurableSubmittedTurn: runUi.kind !== 'continue', }); + const returnedPlannerFailure = plannerRequestFailureUpdate(res?.updates); + if (returnedPlannerFailure && sameTabId(currentTabId, tabId) && !isTabAbortRequested(tabId)) { + renderPlannerRequestFailure( + assistantEl, + returnedPlannerFailure.data, + retryPayloadForRunAssistant(assistantEl), + ); + } const returnedErrorUpdate = Array.isArray(res?.updates) ? res.updates.find(update => update?.type === 'error') : null; @@ -5002,7 +5010,7 @@ function bindErrorRetryButton(btn) { } function rebindRetryButtons() { - document.querySelectorAll('.error-retry-btn').forEach(bindErrorRetryButton); + document.querySelectorAll('.error-retry-btn, .planner-request-failure-retry-btn').forEach(bindErrorRetryButton); } function createActiveChatPayloadState(retryPayload, requestId = '') { @@ -5041,6 +5049,94 @@ function retryPayloadForRunAssistant(assistantEl) { }; } +function plannerRequestFailureUpdate(updates = []) { + if (!Array.isArray(updates)) return null; + return updates.find(update => ( + update?.type === 'warning' + && update?.data?.code === 'planner_request_failed' + )) || null; +} + +function bindPlannerProviderSettingsButton(btn) { + if (!btn || btn.dataset.bound) return; + btn.dataset.bound = 'true'; + btn.addEventListener('click', async (event) => { + event.preventDefault(); + event.stopPropagation(); + btn.disabled = true; + try { + await openProvidersSettingsPage(); + } finally { + btn.disabled = false; + } + }); +} + +function rebindPlannerRequestFailureControls() { + document.querySelectorAll('.planner-request-failure-provider-btn') + .forEach(bindPlannerProviderSettingsButton); +} + +function renderPlannerRequestFailure(assistantEl, data, retryPayload = null) { + if (!assistantEl || data?.code !== 'planner_request_failed') return false; + const textEl = assistantEl.querySelector('.message-text'); + if (!textEl) return false; + if (textEl.querySelector('.planner-request-failure-actions')) return true; + + clearAssistantTextStreamState(assistantEl); + assistantEl.classList.add('planner-request-failure'); + textEl.classList.add('planner-request-failure-content'); + textEl.replaceChildren(); + // Set the role on the empty container first: screen readers announce + // content inserted into an existing live region, not a region that arrives + // already populated. A restored card stays silent, which is what we want. + textEl.setAttribute('role', 'alert'); + + const message = document.createElement('div'); + message.className = 'planner-request-failure-message'; + message.textContent = data.message || 'Planner request failed before a valid response was available.'; + // Name the provider that failed — with several configured, "open Providers" + // is only actionable if the user knows which one to look at. The label is a + // proper noun, so it needs no translation. + if (data.provider) { + const providerName = document.createElement('div'); + providerName.className = 'planner-request-failure-provider'; + providerName.textContent = data.provider; + message.appendChild(providerName); + } + + const actions = document.createElement('div'); + actions.className = 'planner-request-failure-actions'; + + const retryBtn = document.createElement('button'); + retryBtn.type = 'button'; + retryBtn.className = 'planner-request-failure-action planner-request-failure-retry-btn'; + retryBtn.textContent = t('sp.retry'); + retryBtn.title = t('sp.retry'); + retryBtn.setAttribute('aria-label', t('sp.retry')); + const retryReady = configureRetryButton(retryBtn, retryPayload); + + const providerBtn = document.createElement('button'); + providerBtn.type = 'button'; + providerBtn.className = 'planner-request-failure-action planner-request-failure-provider-btn'; + providerBtn.textContent = t('st.tab.providers'); + providerBtn.title = `${t('sp.btn.settings')}: ${t('st.tab.providers')}`; + providerBtn.setAttribute('aria-label', providerBtn.title); + bindPlannerProviderSettingsButton(providerBtn); + + const orderedActions = data.failureKind === 'auth' + ? [providerBtn, retryReady ? retryBtn : null] + : [retryReady ? retryBtn : null, providerBtn]; + const visibleActions = orderedActions.filter(Boolean); + visibleActions[0]?.classList.add('primary'); + visibleActions.forEach(btn => actions.appendChild(btn)); + + textEl.append(message, actions); + addMessageCopyButton(assistantEl); + scrollToBottom(); + return true; +} + function clearActiveChatPayloadForTab(tabId) { if (tabId != null) activeChatPayloadsByTab.delete(tabId); } @@ -5099,6 +5195,7 @@ function rebindRestoredMessageControls() { rebindCopyButtons(); rebindScreenshotSaveButtons(); rebindRetryButtons(); + rebindPlannerRequestFailureControls(); rebindContinueButtons(); rebindClarifyCards(); rebindPlanReviewCards(); @@ -6721,6 +6818,14 @@ async function sendMessage(extraChatParams = {}) { accepted = true; completedSuccessfully = res?.successfulDone === true || updatesContainSuccessfulDone(res?.updates); promptEligibleCompletion = completedSuccessfully || isSuccessfulAskCompletion(modeForSend, res); + const returnedPlannerFailure = plannerRequestFailureUpdate(res?.updates); + if (returnedPlannerFailure + && renderToCurrentTab + && currentTabId === tabId + && !isTabAbortRequested(tabId) + && !clearedConversationRunRequestIds.has(requestId)) { + renderPlannerRequestFailure(assistantEl, returnedPlannerFailure.data, retryPayload); + } const returnedErrorUpdate = Array.isArray(res?.updates) ? res.updates.find(u => u?.type === 'error') : null; @@ -7054,7 +7159,12 @@ function handleAgentUpdateMessage(msg) { case 'warning': hideActivity(); - if (data?.code === 'ask_stream_fallback') { + if (data?.code === 'planner_request_failed') { + const targetAssistantEl = eventAssistantEl || currentAssistantEl; + const retryPayload = activeRetryPayloadForRequest(eventTabId, msg.requestId) + || retryPayloadForRunAssistant(targetAssistantEl); + renderPlannerRequestFailure(targetAssistantEl, data, retryPayload); + } else if (data?.code === 'ask_stream_fallback') { showComposerToast(t('sp.streaming.fallback'), { duration: 6000 }); } break; @@ -8399,7 +8509,8 @@ function configureRetryButton(btn, retryPayload) { } function addErrorRetryButton(msgEl, retryPayload) { - if (!msgEl || !retryPayload?.text || msgEl.querySelector('.error-retry-btn, .cost-allowance-retry-btn')) return; + if (!msgEl || !retryPayload?.text + || msgEl.querySelector('.error-retry-btn, .cost-allowance-retry-btn, .planner-request-failure-retry-btn')) return; msgEl.classList.add('retryable'); const btn = document.createElement('button'); btn.type = 'button'; diff --git a/src/firefox/styles/sidepanel.css b/src/firefox/styles/sidepanel.css index 081d4c7c1..365822144 100644 --- a/src/firefox/styles/sidepanel.css +++ b/src/firefox/styles/sidepanel.css @@ -2745,6 +2745,79 @@ body { background: var(--accent-hover); } +/* Planner transport/auth failure — actionable assistant card */ +.message.assistant.planner-request-failure .message-content { + background: rgba(244, 67, 54, 0.08); + border-color: rgba(244, 67, 54, 0.3); +} + +.planner-request-failure-content { + display: flex; + flex-direction: column; + gap: 10px; +} + +.planner-request-failure-message { + color: var(--error-soft); +} + +.planner-request-failure-provider { + margin-top: 4px; + font-size: 11px; + font-weight: 600; + opacity: 0.85; +} + +.planner-request-failure-actions { + display: flex; + flex-wrap: wrap; + gap: 7px; +} + +.planner-request-failure-action { + min-height: 30px; + padding: 5px 11px; + border: 1px solid rgba(244, 67, 54, 0.35); + border-radius: 6px; + background: rgba(244, 67, 54, 0.06); + color: var(--error-soft); + font: inherit; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s; +} + +.planner-request-failure-action.primary { + border-color: var(--accent); + background: var(--accent); + color: #fff; +} + +.planner-request-failure-action:hover:not(:disabled), +.planner-request-failure-action:focus-visible { + border-color: rgba(244, 67, 54, 0.6); + background: rgba(244, 67, 54, 0.16); + color: var(--text-primary); +} + +.planner-request-failure-action.primary:hover:not(:disabled), +.planner-request-failure-action.primary:focus-visible { + border-color: var(--accent-hover); + background: var(--accent-hover); + color: #fff; +} + +.planner-request-failure-action:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.planner-request-failure-action:disabled { + cursor: wait; + opacity: 0.65; +} + /* Error messages */ .message.error .message-content { background: rgba(244, 67, 54, 0.1); diff --git a/test/run.js b/test/run.js index 6ebdaa2c2..e48061a0a 100644 --- a/test/run.js +++ b/test/run.js @@ -259,6 +259,12 @@ const ConfigTransferCh = await import( const ConfigTransferFx = await import( 'file://' + path.join(ROOT, 'src/firefox/src/config-transfer.js').replace(/\\/g, '/') ); +const RuntimeTraceConfigCh = await import( + 'file://' + path.join(ROOT, 'src/chrome/src/trace/runtime-config.js').replace(/\\/g, '/') +); +const RuntimeTraceConfigFx = await import( + 'file://' + path.join(ROOT, 'src/firefox/src/trace/runtime-config.js').replace(/\\/g, '/') +); const DownloadDirectoryCh = await import( 'file://' + path.join(ROOT, 'src/chrome/src/download-directory.js').replace(/\\/g, '/') ); @@ -4062,12 +4068,72 @@ test('trace record and JSON exports carry WebBrain version metadata', () => { const traceUi = fs.readFileSync(path.join(ROOT, prefix, 'src/ui/traces.js'), 'utf8'); const agent = fs.readFileSync(path.join(ROOT, prefix, 'src/agent/agent.js'), 'utf8'); assert.match(recorder, /webbrainVersion: meta\.webbrainVersion \|\| ''/, `${label}: run record should retain the recording version`); + 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(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', () => { + const candidate = { + schema_version: 99, + extension_version: '24.7.0-beta.1', + browser_target: 'chrome', + mode: 'act', + prompt_tier: 'compact', + plan_before_act_mode: 'try', + auto_screenshot: 'state_change', + image_detail: 'low', + screenshot_redaction: true, + strict_secret_mode: false, + use_site_adapters: true, + web_mcp_enabled: false, + api_mutations_allowed: true, + user_memory_enabled: true, + selection_grounded: false, + max_agent_steps: 130, + max_image_dimension: 1568, + max_screenshots_per_turn: 4, + api_key: 'must-not-leak', + profile_text: 'must-not-leak', + arbitrary_nested_settings: { token: 'must-not-leak' }, + }; + const expected = { + schema_version: 1, + extension_version: '24.7.0-beta.1', + browser_target: 'chrome', + mode: 'act', + prompt_tier: 'compact', + plan_before_act_mode: 'try', + auto_screenshot: 'state_change', + image_detail: 'low', + screenshot_redaction: true, + strict_secret_mode: false, + use_site_adapters: true, + web_mcp_enabled: false, + api_mutations_allowed: true, + user_memory_enabled: true, + selection_grounded: false, + max_agent_steps: 130, + max_image_dimension: 1568, + max_screenshots_per_turn: 4, + }; + assert.deepEqual(RuntimeTraceConfigCh.normalizeRuntimeTraceConfig(candidate), expected); + assert.deepEqual(RuntimeTraceConfigFx.normalizeRuntimeTraceConfig(candidate), expected); + + const rejected = RuntimeTraceConfigCh.normalizeRuntimeTraceConfig({ + extension_version: 'bad version with spaces', + browser_target: 'safari', + mode: 'admin', + max_agent_steps: Infinity, + max_image_dimension: 42, + screenshot_redaction: 'yes', + }); + assert.deepEqual(rejected, { schema_version: 1 }); +}); + test('trace recorders normalize done only from explicit loop error evidence', () => { for (const [label, prefix] of [ ['chrome', 'src/chrome'], @@ -4887,6 +4953,8 @@ test('tool-free response and recovery calls honor Stop before rendering model ou assert.deepEqual(result, { content: '[Stopped by user]', status: 'cancelled' }, `${label}: ${phase} ignored Stop`); assert.equal(messages.at(-1)?.content, '[Stopped by user]', `${label}: ${phase} persisted late model output`); + assert.equal(messages.at(-1)?.webbrainLocalStatus, 'cancelled', `${label}: ${phase} cancellation was not marked UI-only`); + assert.equal(agent._modelVisibleConversationMessages(messages).includes(messages.at(-1)), false, `${label}: ${phase} cancellation remained model-visible`); assert.equal(updates.some(update => /late model output/.test(update.data?.content || '')), false, `${label}: ${phase} rendered late model output`); assert.equal(agent.abortFlags.has(tabId), false, `${label}: ${phase} left the abort flag pending`); } @@ -32279,6 +32347,9 @@ test('WebBrain Cloud groups every generation in a stable conversation session wi const tabId = label === 'chrome' ? 731 : 732; const agent = new AgentClass({}); agent.getConversation(tabId, 'ask'); + agent.screenshotRedaction = true; + agent.strictSecretMode = true; + agent.apiAllowedTabs.add(tabId); const firstConversationId = agent.conversationIds.get(tabId); assert.match(firstConversationId, /^conv_/, `${label}: new conversation should mint an id`); @@ -32288,6 +32359,13 @@ test('WebBrain Cloud groups every generation in a stable conversation session wi assert.equal(main.webbrainSessionId, firstConversationId, `${label}: main call should carry the conversation id`); assert.equal(planner.webbrainSessionId, firstConversationId, `${label}: planner call should share the main session`); assert.equal(planner.webbrainGenerationName, 'planner', `${label}: planner call should be labeled`); + assert.equal(main.webbrainRuntimeConfig?.schema_version, 1, `${label}: runtime trace schema missing`); + assert.equal(main.webbrainRuntimeConfig?.browser_target, label, `${label}: browser target missing`); + assert.equal(main.webbrainRuntimeConfig?.mode, 'ask', `${label}: effective mode missing`); + assert.equal(main.webbrainRuntimeConfig?.screenshot_redaction, true, `${label}: screenshot redaction setting missing`); + 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`); const byoOptions = agent._cloudGenerationOptions({ config: { providerName: 'openai' } }, { temperature: 0 }, { tabId, generationName: 'memory' }); assert.deepEqual(byoOptions, { temperature: 0 }, `${label}: BYO provider received Cloud collection fields`); @@ -32304,9 +32382,16 @@ test('WebBrain Cloud groups every generation in a stable conversation session wi cloudProvider._addWebBrainCloudContext(cloudBody, { webbrainSessionId: firstConversationId, webbrainGenerationName: 'compaction', + webbrainRuntimeConfig: { + ...main.webbrainRuntimeConfig, + api_key: 'must-not-leak', + profile_text: 'must-not-leak', + }, }); assert.equal(cloudBody.session_id, firstConversationId, `${label}: Cloud request body should include session_id`); assert.equal(cloudBody.trace?.generation_name, 'compaction', `${label}: Cloud request body should include the generation label`); + assert.deepEqual(cloudBody.trace?.runtime_config, main.webbrainRuntimeConfig, `${label}: Cloud request body should include only allowlisted runtime settings`); + assert.ok(!JSON.stringify(cloudBody).includes('must-not-leak'), `${label}: arbitrary settings leaked into Cloud trace context`); const byoProvider = new Provider({ providerName: 'openai', apiKey: 'test-key' }); const byoBody = {}; @@ -41266,6 +41351,46 @@ function executionToolResponses(prefix = 'execution') { ]; } +test('local cancellation statuses stay visible but are excluded from model and planner context', () => { + for (const AgentClass of [AgentCh, AgentFx]) { + const provider = { + supportsTools: true, + supportsVision: false, + promptTier: 'full', + contextWindow: 128000, + model: 'test-model', + name: 'test-provider', + }; + const agent = new AgentClass({ getActive: () => provider, getVisionProvider: async () => null }); + const marked = agent._localCancellationMessage('[Stopped by user]'); + const messages = [ + { role: 'system', content: 'sys' }, + { role: 'user', content: 'First task.' }, + marked, + { role: 'assistant', content: '[Stopped by user' }, + { role: 'user', content: 'How should I respond to this?' }, + ]; + + const modelMessages = agent._messagesForSourceGroundedRun(messages); + const digest = agent._buildPlannerHistoryDigest(messages); + + assert.equal(marked.webbrainLocalStatus, 'cancelled', `${AgentClass.name}: cancellation lacks local status metadata`); + assert.equal(messages.length, 5, `${AgentClass.name}: model filtering mutated visible history`); + assert.equal( + modelMessages.some(message => agent._isLocalCancellationText(message.content)), + false, + `${AgentClass.name}: cancellation leaked into model context`, + ); + assert.doesNotMatch(digest, /Stopped by user/, `${AgentClass.name}: cancellation leaked into planner digest`); + assert.match(digest, /How should I respond to this\?/, `${AgentClass.name}: latest real user task was filtered`); + assert.equal( + agent._modelVisibleConversationMessages([marked]).length, + 0, + `${AgentClass.name}: marked cancellation was not treated as UI-only`, + ); + } +}); + test('Act rejects planner-shaped plain finals and continues into a real tool', async () => { for (const [index, AgentClass] of [AgentCh, AgentFx].entries()) { const responses = [ @@ -41349,6 +41474,113 @@ test('Act routes ordinary plain finals through the language-neutral done protoco } }); +test('Act omits a stale cancellation echo and recovers into tools', async () => { + for (const [index, AgentClass] of [AgentCh, AgentFx].entries()) { + const responses = [ + { content: '[Stopped by user]', toolCalls: [] }, + ...executionToolResponses(`stale_cancel_${index}`), + ]; + const requests = []; + const provider = { + supportsTools: true, + supportsVision: false, + promptTier: 'full', + contextWindow: 128000, + model: 'test-model', + name: 'test-provider', + chat: async (messages) => { + requests.push(messages); + const next = responses.shift(); + assert.ok(next, `${AgentClass.name}: stale-cancellation recovery called the model too many times`); + return next; + }, + }; + const agent = new AgentClass({ getActive: () => provider, getVisionProvider: async () => null }); + const tabId = 8650 + index; + configurePlanOnlyGuardAgent(agent, tabId); + + const final = await agent.processMessage(tabId, 'Read the open email and draft a reply.', () => {}, 'act'); + + assert.equal(final, 'Executed and verified.', `${AgentClass.name}: stale cancellation stopped execution`); + assert.equal(responses.length, 0, `${AgentClass.name}: stale cancellation did not recover through tools`); + assert.equal( + requests[1].some(message => agent._isLocalCancellationText(message.content)), + false, + `${AgentClass.name}: stale cancellation was replayed to the recovery turn`, + ); + assert.ok( + requests[1].some(message => message.role === 'assistant' && /Stale local cancellation status omitted/.test(message.content || '')), + `${AgentClass.name}: recovery context lacks the neutral replacement`, + ); + assert.ok( + requests[1].some(message => message.role === 'user' && /No current user stop was received/.test(message.content || '')), + `${AgentClass.name}: recovery nudge did not distinguish a stale status from a real abort`, + ); + } +}); + +test('Act fails transparently when a stale cancellation echo repeats', async () => { + for (const [index, AgentClass] of [AgentCh, AgentFx].entries()) { + const responses = [ + { content: '[Stopped by user]', toolCalls: [] }, + { content: '[Stopped by user', toolCalls: [] }, + ]; + const provider = { + supportsTools: true, + supportsVision: false, + promptTier: 'full', + contextWindow: 128000, + model: 'test-model', + name: 'test-provider', + chat: async () => responses.shift(), + }; + const agent = new AgentClass({ getActive: () => provider, getVisionProvider: async () => null }); + const tabId = 8655 + index; + configurePlanOnlyGuardAgent(agent, tabId); + + const final = await agent.processMessage(tabId, 'Read the open email and draft a reply.', () => {}, 'act'); + + assert.match(final, /repeated a stale cancellation status/, `${AgentClass.name}: repeated stale cancellation was misreported`); + assert.match(final, /No current user stop was received/, `${AgentClass.name}: failure implied the user cancelled`); + assert.match(final, /no successful action was verified/i, `${AgentClass.name}: failure claimed execution evidence`); + } +}); + +test('Act preserves successful tool evidence when a stale cancellation echo repeats', async () => { + for (const [index, AgentClass] of [AgentCh, AgentFx].entries()) { + const responses = [ + { content: '[Stopped by user]', toolCalls: [] }, + { + content: null, + toolCalls: [{ + id: `stale_evidence_${index}`, + function: { name: 'read_page', arguments: '{}' }, + }], + }, + { content: '[Stopped by user]', toolCalls: [] }, + ]; + const provider = { + supportsTools: true, + supportsVision: false, + promptTier: 'full', + contextWindow: 128000, + model: 'test-model', + name: 'test-provider', + chat: async () => responses.shift(), + }; + const agent = new AgentClass({ getActive: () => provider, getVisionProvider: async () => null }); + const tabId = 8657 + index; + configurePlanOnlyGuardAgent(agent, tabId); + + const final = await agent.processMessage(tabId, 'Read the open email and draft a reply.', () => {}, 'act'); + + assert.match(final, /repeated a stale cancellation status/, `${AgentClass.name}: repeated stale cancellation was misreported`); + assert.match(final, /Some task tools completed/, `${AgentClass.name}: successful tool evidence was discarded`); + assert.match(final, /Inspect the current state before retrying/, `${AgentClass.name}: retry warning was omitted`); + assert.doesNotMatch(final, /no successful action was verified/i, `${AgentClass.name}: failure contradicted verified tool evidence`); + } +}); + test('Act recovers localized plain plans without language-specific matchers', async () => { const localizedPlans = [ 'Önce sayfayı okuyacağım, sonra sonucu özetleyeceğim.', @@ -47594,6 +47826,8 @@ test('planner: prompt treats page context as untrusted data', () => { assert.match(PLANNER_SYSTEM_PROMPT, /attached JSON\/TXT\/CSV text file content/); assert.match(PLANNER_SYSTEM_PROMPT, /brief neutral scratchpad_notes/); assert.match(PLANNER_SYSTEM_PROMPT, /Do not plan to copy the full file/); + assert.match(PLANNER_SYSTEM_PROMPT, /How should I respond to this open email\?.*execute/i); + assert.match(PLANNER_SYSTEM_PROMPT, /respond must not include steps that need page, browser, network, memory, or scheduling tools/i); assert.match(PLANNER_SYSTEM_PROMPT, /lacks usable timing or cadence.*clarify/i); assert.match(PLANNER_SYSTEM_PROMPT, /precise fixed interval.*every five minutes.*start now/i); assert.match(PLANNER_SYSTEM_PROMPT, /Calendar\/cron recurrence.*not supported/i); @@ -47609,6 +47843,8 @@ test('planner: prompt treats page context as untrusted data', () => { assert.match(PLANNER_INTENT_SYSTEM_PROMPT, /use find_text to select one page-text match instead of Ctrl\/Cmd\+F/); assert.match(PLANNER_INTENT_SYSTEM_PROMPT, /Each call replaces the previous selection/); assert.match(PLANNER_INTENT_SYSTEM_PROMPT, /cannot create simultaneous highlights or browser Find UI/); + assert.match(PLANNER_INTENT_SYSTEM_PROMPT, /How should I respond to this open email\?.*execute/i); + assert.match(PLANNER_INTENT_SYSTEM_PROMPT, /respond must not include steps that need page, browser, network, memory, or scheduling tools/i); assert.match(PLANNER_SYSTEM_PROMPT_FX, /lacks usable timing or cadence.*clarify/i); assert.match(PLANNER_INTENT_SYSTEM_PROMPT_FX, /Calendar\/cron recurrence.*unsupported/i); assert.match(PLANNER_INTENT_SYSTEM_PROMPT_FX, /"use_progress_ledger": boolean/); @@ -47806,6 +48042,227 @@ test('planner routes existing-context artifact requests to a tool-free response' }); }); +test('planner rechecks tool-dependent respond and plan-only intents before routing', async () => { + await withPlannerBrowserGlobals(async () => { + for (const [agentIndex, AgentClass] of [AgentCh, AgentFx].entries()) { + for (const [routeIndex, route] of ['intent', 'full'].entries()) { + for (const [kindIndex, requestKind] of ['respond', 'plan_only'].entries()) { + const responses = [ + plannerFixtureJson({ + request_kind: requestKind, + requires_state_change: false, + summary: 'Read the open email and draft a response.', + steps: [{ id: '1', action: 'Read the open email.', tools: ['read_page'] }], + localized: { + locale: 'en', + summary: 'Read the open email and draft a response.', + steps: [{ id: '1', action: 'Read the open email.' }], + risks: [], + }, + }), + plannerFixtureJson({ + request_kind: 'execute', + requires_state_change: false, + summary: 'Read the open email and draft a response.', + steps: [ + { id: '1', action: 'Read the open email.', tools: ['read_page'] }, + { id: '2', action: 'Return reply drafts.', tools: ['done'] }, + ], + localized: { + locale: 'en', + summary: 'Read the open email and draft a response.', + steps: [ + { id: '1', action: 'Read the open email.' }, + { id: '2', action: 'Return reply drafts.' }, + ], + risks: [], + }, + }), + ]; + const plannerRequests = []; + const provider = { + promptTier: 'full', + model: 'planner-test', + name: 'planner-test', + chat: async (messages) => { + plannerRequests.push(messages); + const content = responses.shift(); + assert.ok(content, `${AgentClass.name}: ${route}/${requestKind} planner called too many times`); + return { content, usage: {} }; + }, + }; + const agent = new AgentClass({ getActive: () => provider, getVisionProvider: async () => null }); + const tabId = 9280 + (agentIndex * 20) + (routeIndex * 10) + kindIndex; + const args = [ + tabId, + { role: 'user', content: 'How should I respond to this?' }, + () => {}, + null, + null, + '', + { tabUrl: 'https://mail.google.com/', tabTitle: 'The Next New Thing' }, + ]; + const gate = route === 'intent' + ? await agent._runPlannerIntentGate(...args, 'act', { locale: 'en' }) + : await agent._runPlannerGate(...args, 'try', 'act', { locale: 'en' }); + + assert.equal(gate.proceed, true, `${AgentClass.name}: ${route}/${requestKind} did not recover`); + assert.equal(gate.requestKind, 'execute', `${AgentClass.name}: ${route}/${requestKind} stayed tool-free`); + assert.equal(gate.requiresStateChange, false, `${AgentClass.name}: read-only repair gained mutation authority`); + assert.equal(plannerRequests.length, 2, `${AgentClass.name}: ${route}/${requestKind} should get one consistency repair`); + assert.match( + plannerRequests[1].at(-1)?.content || '', + new RegExp(requestKind === 'respond' ? 'respond_with_tools' : 'plan_only_with_execution_tools'), + `${AgentClass.name}: ${route}/${requestKind} missing focused consistency guidance`, + ); + assert.equal(responses.length, 0, `${AgentClass.name}: ${route}/${requestKind} left an unused response`); + } + } + } + }); +}); + +test('planner consistency repair keeps model-derived plan fields out of trusted messages', () => { + for (const AgentClass of [AgentCh, AgentFx]) { + const provider = { + promptTier: 'full', + model: 'planner-test', + name: 'planner-test', + }; + const agent = new AgentClass({ getActive: () => provider, getVisionProvider: async () => null }); + const plannerMessages = [ + { role: 'system', content: 'Planner system prompt.' }, + { role: 'user', content: 'Authoritative user task.' }, + ]; + const plan = { + request_kind: 'respond', + summary: 'MODEL_DERIVED_INJECTION_TEXT', + steps: [{ + id: '1', + action: 'MODEL_DERIVED_INJECTION_TEXT', + tools: ['MODEL_DERIVED_INJECTION_TOOL'], + }], + }; + const issue = agent._plannerIntentConsistencyIssue(plan); + const repairMessages = agent._plannerIntentConsistencyRepairMessages(plannerMessages, issue); + const appendedMessages = repairMessages.slice(plannerMessages.length); + + assert.equal(appendedMessages.length, 1, `${AgentClass.name}: repair replayed the prior planner output`); + assert.equal(appendedMessages[0]?.role, 'user', `${AgentClass.name}: repair added a trusted assistant replay`); + assert.match(appendedMessages[0]?.content || '', /respond_with_tools/, `${AgentClass.name}: repair lost the controlled issue kind`); + assert.doesNotMatch( + appendedMessages[0]?.content || '', + /MODEL_DERIVED_INJECTION/, + `${AgentClass.name}: model-derived plan data crossed the trust boundary`, + ); + } +}); + +test('planner falls back safely when repaired respond intent still lists tools', async () => { + await withPlannerBrowserGlobals(async () => { + for (const [agentIndex, AgentClass] of [AgentCh, AgentFx].entries()) { + for (const [routeIndex, route] of ['intent', 'full'].entries()) { + for (const [scenarioIndex, [scenarioLabel, firstResponse]] of [ + ['repeated', null], + ['json_repair', 'not valid planner JSON'], + ].entries()) { + const inconsistentRespond = plannerFixtureJson({ + request_kind: 'respond', + requires_state_change: false, + summary: 'Read the open email and draft a response.', + steps: [{ id: '1', action: 'Read the open email.', tools: ['read_page'] }], + localized: { + locale: 'en', + summary: 'Read the open email and draft a response.', + steps: [{ id: '1', action: 'Read the open email.' }], + risks: [], + }, + }); + const responses = [ + firstResponse ?? inconsistentRespond, + inconsistentRespond, + ]; + let calls = 0; + const provider = { + promptTier: 'full', + model: 'planner-test', + name: 'planner-test', + chat: async () => { + calls++; + return { content: responses.shift(), usage: {} }; + }, + }; + const agent = new AgentClass({ getActive: () => provider, getVisionProvider: async () => null }); + const tabId = 9310 + (agentIndex * 20) + (routeIndex * 10) + scenarioIndex; + const args = [ + tabId, + { role: 'user', content: 'How should I respond to this?' }, + () => {}, + null, + null, + '', + { tabUrl: 'https://mail.google.com/', tabTitle: 'The Next New Thing' }, + ]; + const gate = route === 'intent' + ? await agent._runPlannerIntentGate(...args, 'act', { locale: 'en' }) + : await agent._runPlannerGate(...args, 'try', 'act', { locale: 'en' }); + + assert.equal(calls, 2, `${AgentClass.name}: ${route}/${scenarioLabel} exceeded the one-repair budget`); + assert.equal(gate.proceed, true, `${AgentClass.name}: ${route}/${scenarioLabel} did not enter the safe fallback`); + assert.equal(gate.readOnlyFallback, true, `${AgentClass.name}: ${route}/${scenarioLabel} accepted an unresolved intent`); + assert.notEqual(gate.responseOnly, true, `${AgentClass.name}: ${route}/${scenarioLabel} still routed tool-dependent work response-only`); + assert.equal(gate.requiresStateChange, false, `${AgentClass.name}: ${route}/${scenarioLabel} fallback gained mutation authority`); + } + } + } + }); +}); + +test('planner consistency repair preserves an explicit plan-only request', async () => { + await withPlannerBrowserGlobals(async () => { + for (const [index, AgentClass] of [AgentCh, AgentFx].entries()) { + const planOnly = plannerFixtureJson({ + request_kind: 'plan_only', + requires_state_change: false, + summary: 'Outline how to review the open email later.', + steps: [{ id: '1', action: 'Plan a later email read.', tools: ['read_page'] }], + localized: { + locale: 'en', + summary: 'Outline how to review the open email later.', + steps: [{ id: '1', action: 'Plan a later email read.' }], + risks: [], + }, + }); + let calls = 0; + const provider = { + promptTier: 'full', + model: 'planner-test', + name: 'planner-test', + chat: async () => { + calls++; + return { content: planOnly, usage: {} }; + }, + }; + const agent = new AgentClass({ getActive: () => provider, getVisionProvider: async () => null }); + const gate = await agent._runPlannerIntentGate( + 9330 + index, + { role: 'user', content: 'Only give me a plan for reviewing this email later.' }, + () => {}, + null, + null, + '', + { tabUrl: 'https://mail.google.com/', tabTitle: 'Inbox' }, + 'act', + { locale: 'en' }, + ); + + assert.equal(calls, 2, `${AgentClass.name}: ambiguous plan-only output was not rechecked once`); + assert.equal(gate.proceed, false, `${AgentClass.name}: explicit plan-only request was silently authorized`); + assert.equal(gate.reason, 'plan_only', `${AgentClass.name}: explicit plan-only intent changed`); + } + }); +}); + test('planner validates semantic skill ids and activates approved skills before execution', async () => { await withPlannerBrowserGlobals(async () => { for (const [label, AgentClass, prefix] of [ @@ -49084,8 +49541,8 @@ test('planner request errors stop accurately instead of masquerading as JSON rep calls += 1; throw new Error('401 invalid API key'); }; - let warning = ''; - const onUpdate = (type, data) => { if (type === 'warning') warning = data?.message || ''; }; + let warning = null; + const onUpdate = (type, data) => { if (type === 'warning') warning = data || null; }; const intent = await agent._runPlannerIntentGate( tabId, @@ -49099,10 +49556,15 @@ test('planner request errors stop accurately instead of masquerading as JSON rep assert.equal(intent.requestKind, 'respond', `${label}: provider failure was mislabeled as ambiguous intent`); assert.equal(intent.readOnlyFallback, undefined, `${label}: provider failure was treated as invalid JSON`); assert.match(intent.message || '', /Planner request failed.*401 invalid API key/i, `${label}: intent request error hid the provider failure`); - assert.equal(warning, intent.message, `${label}: intent request warning diverged from the terminal error`); + assert.match(intent.message || '', /No tools ran\.$/, `${label}: planner failure did not state that execution never began`); + assert.equal(intent.failureKind, 'auth', `${label}: 401 planner failure was not classified as an authentication problem`); + assert.equal(warning?.code, 'planner_request_failed', `${label}: planner request failure warning lacks a stable UI code`); + assert.equal(warning?.failureKind, 'auth', `${label}: planner request failure warning lost its authentication category`); + assert.equal(warning?.provider, 'broken-provider', `${label}: planner request failure warning lost its provider label`); + assert.equal(warning?.message, intent.message, `${label}: intent request warning diverged from the terminal error`); calls = 0; - warning = ''; + warning = null; const full = await agent._runPlannerGate( tabId, { role: 'user', content: 'Perform this task.' }, @@ -49114,11 +49576,183 @@ test('planner request errors stop accurately instead of masquerading as JSON rep assert.equal(full.reason, 'planner_error', `${label}: full planner request error lost its planner status`); assert.equal(full.readOnlyFallback, undefined, `${label}: full planner provider failure was treated as invalid JSON`); assert.match(full.message || '', /Planner request failed.*401 invalid API key/i, `${label}: full planner request error hid the provider failure`); - assert.equal(warning, full.message, `${label}: full planner warning diverged from the terminal error`); + assert.equal(warning?.message, full.message, `${label}: full planner warning diverged from the terminal error`); + + agent._chatWithCostAllowance = async () => { + throw new Error('openrouter error 503: temporarily unavailable'); + }; + warning = null; + const transient = await agent._runPlannerGate( + tabId, + { role: 'user', content: 'Perform this task later.' }, + onUpdate, + null, + ); + assert.equal(transient.failureKind, 'transient', `${label}: 503 planner failure was not classified as transient`); + assert.equal(warning?.failureKind, 'transient', `${label}: transient category was not exposed to the sidepanel`); + + // The category only reorders two always-present buttons, but it is the + // whole point of the card: an auth failure must lead with Providers and + // a dropped connection must lead with Retry. Classification runs on raw + // provider prose, so pin the shapes real providers actually emit — + // including the browser's own fetch failures, which is what a stopped + // local server looks like from inside the extension. + const classifications = [ + ['Incorrect API key provided: sk-***. (status 401)', 'auth', 'parenthesized 401'], + ['Unauthorized', 'auth', 'status-free auth rejection'], + ['invalid_api_key: authentication failed', 'auth', 'textual auth rejection'], + ['TypeError: Failed to fetch', 'transient', 'Chrome transport failure'], + ['NetworkError when attempting to fetch resource.', 'transient', 'Firefox transport failure'], + ['The request timed out after 60000 ms', 'transient', 'timeout'], + ['ECONNREFUSED 127.0.0.1:11434', 'transient', 'unreachable local provider'], + ['Rate limit reached. Please try again (429)', 'transient', 'rate limit'], + ['Provider returned 400: max_tokens 512 is invalid', 'provider', 'rejected request carrying a 5xx-shaped number'], + ['This model requires 8192 context, got 500 tokens', 'provider', 'prose carrying a 5xx-shaped number'], + ['insufficient_quota: You exceeded your current quota', 'provider', 'billing failure'], + ]; + for (const [detail, expected, why] of classifications) { + agent._chatWithCostAllowance = async () => { throw new Error(detail); }; + warning = null; + const classified = await agent._runPlannerGate( + tabId, + { role: 'user', content: 'Classify this failure.' }, + onUpdate, + null, + ); + assert.equal( + classified.failureKind, + expected, + `${label}: ${why} ("${detail}") should recover as ${expected}`, + ); + assert.equal( + warning?.failureKind, + expected, + `${label}: ${why} lost its category on the way to the sidepanel`, + ); + assert.match( + classified.message || '', + /[.!?] No tools ran\.$/, + `${label}: ${why} produced a run-on failure message`, + ); + } } }); }); +test('planner failure category survives the planner gate wrapper', async () => { + await withPlannerBrowserGlobals(async () => { + for (const [label, AgentClass] of [['chrome', AgentCh], ['firefox', AgentFx]]) { + const tabId = label === 'chrome' ? 9157 : 9158; + const provider = { name: 'broken-provider', model: 'broken-model', config: {} }; + const agent = new AgentClass({ getActive: () => provider }); + agent._chatWithCostAllowance = async () => { throw new Error('401 invalid API key'); }; + + const messages = []; + const outcome = await agent._maybeRunPlannerGate( + tabId, + messages, + { role: 'user', content: 'Perform this task.' }, + () => {}, + 'act', + null, + null, + ); + assert.equal(outcome.proceed, false, `${label}: planner failure unexpectedly continued into execution`); + assert.equal(outcome.reason, 'planner_error', `${label}: planner failure lost its planner status`); + assert.equal( + outcome.failureKind, + 'auth', + `${label}: the gate wrapper dropped the failure category before any caller could read it`, + ); + } + }); +}); + +test('planner request failures expose provider settings and retry actions in both sidepanels', () => { + for (const [label, panelRel, backgroundRel, cssRel] of [ + ['chrome', 'src/chrome/src/ui/sidepanel.js', 'src/chrome/src/background.js', 'src/chrome/styles/sidepanel.css'], + ['firefox', 'src/firefox/src/ui/sidepanel.js', 'src/firefox/src/background.js', 'src/firefox/styles/sidepanel.css'], + ]) { + const panel = fs.readFileSync(path.join(ROOT, panelRel), 'utf8'); + const background = fs.readFileSync(path.join(ROOT, backgroundRel), 'utf8'); + const css = fs.readFileSync(path.join(ROOT, cssRel), 'utf8'); + + assert.match( + background, + /function isPlannerRequestFailureUpdate\(update\) \{[\s\S]*?update\?\.type === 'warning'[\s\S]*?update\?\.data\?\.code === 'planner_request_failed';[\s\S]*?\}/, + `${label}: background cannot identify structured planner request failures`, + ); + assert.match( + background, + /function runUpdatesSucceeded\(updates = \[\]\) \{[\s\S]*?isPlannerRequestFailureUpdate\(update\)[\s\S]*?\}/, + `${label}: planner request failure is still counted as a successful run`, + ); + assert.match( + background, + /if \(updates\.some\(update => update\?\.type === 'error' \|\| isPlannerRequestFailureUpdate\(update\)\)\) return 'failed';/, + `${label}: planner request failure does not produce a failed terminal run status`, + ); + assert.match( + panel, + /function renderPlannerRequestFailure\(assistantEl, data, retryPayload = null\) \{[\s\S]*?textEl\.append\(message, actions\);[\s\S]*?addMessageCopyButton\(assistantEl\);[\s\S]*?return true;[\s\S]*?\}/, + `${label}: planner request failure is not rendered as an actionable assistant card`, + ); + assert.match( + panel, + /providerBtn\.textContent = t\('st\.tab\.providers'\);[\s\S]*?await openProvidersSettingsPage\(\);/, + `${label}: Providers action does not lead to the provider settings tab`, + ); + assert.match( + panel, + /const orderedActions = data\.failureKind === 'auth'\s*\? \[providerBtn, retryReady \? retryBtn : null\]\s*: \[retryReady \? retryBtn : null, providerBtn\];/, + `${label}: authentication failures should prioritize Providers while transient failures prioritize Retry`, + ); + assert.match( + panel, + /case 'warning':[\s\S]*?data\?\.code === 'planner_request_failed'[\s\S]*?const targetAssistantEl = eventAssistantEl \|\| currentAssistantEl;[\s\S]*?renderPlannerRequestFailure\(targetAssistantEl, data, retryPayload\);/, + `${label}: live planner request failures bypass the actionable renderer`, + ); + assert.equal( + (panel.match(/plannerRequestFailureUpdate\(res\?\.updates\)/g) || []).length >= 2, + true, + `${label}: returned and restored run responses do not both recover planner failure actions`, + ); + assert.match( + panel, + /document\.querySelectorAll\('\.error-retry-btn, \.planner-request-failure-retry-btn'\)\.forEach\(bindErrorRetryButton\);/, + `${label}: restored planner Retry buttons are not rebound`, + ); + assert.match( + panel, + /planner-request-failure-retry-btn\[data-retry-id\]/, + `${label}: planner Retry attachment payloads are not released with the card`, + ); + assert.match( + panel, + /function rebindPlannerRequestFailureControls\(\) \{[\s\S]*?planner-request-failure-provider-btn[\s\S]*?bindPlannerProviderSettingsButton/, + `${label}: restored Providers buttons are not rebound`, + ); + assert.match( + panel, + /if \(data\.provider\) \{[\s\S]*?providerName\.className = 'planner-request-failure-provider';[\s\S]*?providerName\.textContent = data\.provider;/, + `${label}: the failing provider is never named, so "open Providers" does not say which one to fix`, + ); + assert.match( + panel, + /textEl\.replaceChildren\(\);[\s\S]*?textEl\.setAttribute\('role', 'alert'\);[\s\S]*?const message = document\.createElement\('div'\);/, + `${label}: the failure card is not announced, and the role must precede its content to be spoken`, + ); + assert.match( + panel, + /msgEl\.querySelector\('\.error-retry-btn, \.cost-allowance-retry-btn, \.planner-request-failure-retry-btn'\)/, + `${label}: a planner failure card can be given a second, duplicate Retry affordance`, + ); + assert.match(css, /\.planner-request-failure-actions \{/, `${label}: planner failure action row is not styled`); + assert.match(css, /\.planner-request-failure-action\.primary \{/, `${label}: planner failure primary action is not styled`); + assert.match(css, /\.planner-request-failure-provider \{/, `${label}: planner failure provider label is not styled`); + } +}); + test('planner read-only fallback applies Ask mode to runtime guards without changing the selected mode', async () => { for (const [label, AgentClass, file] of [ ['chrome', AgentCh, 'src/chrome/src/agent/agent.js'],