diff --git a/CHANGELOG.md b/CHANGELOG.md index 36f382dc4..67d7534d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,26 @@ All notable changes to WebBrain are documented in this file. This changelog was generated from the repository Git history and release tags. Versions without a Git tag are inferred from version-bump commits and the current `package.json` / browser manifest versions. +## [26.0.11] - 2026-08-05 + +### Changed +- Routed advice and drafting follow-ups through response-only handling when trusted conversation context is sufficient, even while Act mode is selected. +- Closed built-in tool schemas and made the runtime mode authoritative during execute tasks. +- Kept Turkish deasciification opt-in and instruction-only, with skill instructions loadable only from the enabled catalog after explicit conversion intent. +- Bounded Chrome and Firefox conversation, chat, and run-replay session snapshots so live work can continue safely when recovery persistence is unavailable. + +### Fixed +- Rejected undeclared tool arguments and mixed click targets before dispatch with structured `invalid_tool_arguments` / `noDispatch` results. +- Suppressed planner-shaped JSON beside tool calls, retried planner-shaped terminals once, and replaced raw execute-protocol failures with a user-facing unverified-completion message. +- Prevented unknown required form values from being represented by empty focus, clear, or write actions. +- Normalized nested and object-shaped failures before UI, trace, and dedupe handling so `[object Object]` is never rendered. +- Made assistant-message Copy controls idempotent, added a localized **Copy message** label, and collapsed rejected `done` retries into one visible diagnostic row. +- Preserved acknowledged replay boundaries without false warnings while deduplicating genuine discarded-event gaps per request. +- Retried quota failures with compact snapshots, marked unrecoverable runs non-durable, warned once, and prevented consequential action replay after connection loss without deleting other tab/session data. + +### Tests +- Added mirrored Chrome/Firefox coverage for closed schemas, disabled skill arguments, Act/planner enforcement, nested errors, Copy deduplication, replay boundaries, multi-tab quota exhaustion, attachment/screenshot compaction, and fail-closed reconnect durability. + ## [26.0.0] - 2026-07-26 ### Added diff --git a/dist/webbrain-chrome-26.0.10.zip b/dist/webbrain-chrome-26.0.11.zip similarity index 67% rename from dist/webbrain-chrome-26.0.10.zip rename to dist/webbrain-chrome-26.0.11.zip index e465d3bb8..e6242e312 100644 Binary files a/dist/webbrain-chrome-26.0.10.zip and b/dist/webbrain-chrome-26.0.11.zip differ diff --git a/dist/webbrain-edge-26.0.10.zip b/dist/webbrain-edge-26.0.11.zip similarity index 67% rename from dist/webbrain-edge-26.0.10.zip rename to dist/webbrain-edge-26.0.11.zip index fb43517d3..e6242e312 100644 Binary files a/dist/webbrain-edge-26.0.10.zip and b/dist/webbrain-edge-26.0.11.zip differ diff --git a/dist/webbrain-firefox-26.0.10.zip b/dist/webbrain-firefox-26.0.11.zip similarity index 67% rename from dist/webbrain-firefox-26.0.10.zip rename to dist/webbrain-firefox-26.0.11.zip index 8d115013a..2a78567b8 100644 Binary files a/dist/webbrain-firefox-26.0.10.zip and b/dist/webbrain-firefox-26.0.11.zip differ diff --git a/package-lock.json b/package-lock.json index 283780905..84dc8f904 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "webbrain", - "version": "26.0.10", + "version": "26.0.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "webbrain", - "version": "26.0.10", + "version": "26.0.11", "license": "MIT", "devDependencies": { "playwright": "^1.48.0" diff --git a/package.json b/package.json index 803f65fcb..fa9963266 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "webbrain", - "version": "26.0.10", + "version": "26.0.11", "description": "Open-source AI browser agent — chat with pages, automate tasks, multi-provider LLM support.", "private": true, "type": "module", diff --git a/src/chrome/ARCHITECTURE.md b/src/chrome/ARCHITECTURE.md index a20399112..c7104e0fa 100644 --- a/src/chrome/ARCHITECTURE.md +++ b/src/chrome/ARCHITECTURE.md @@ -1,6 +1,6 @@ # WebBrain Chrome/Edge Extension — Architecture -> Version 26.0.10 · Manifest V3 · Service Worker background +> Version 26.0.11 · Manifest V3 · Service Worker background ## High-Level Overview diff --git a/src/chrome/manifest.json b/src/chrome/manifest.json index 8466c4e15..af6e0874f 100644 --- a/src/chrome/manifest.json +++ b/src/chrome/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "WebBrain", - "version": "26.0.10", + "version": "26.0.11", "description": "Open-source AI browser agent — chat with pages, automate tasks, multi-provider LLM support.", "permissions": [ "sidePanel", diff --git a/src/chrome/src/agent/agent.js b/src/chrome/src/agent/agent.js index 1d3494ac2..bc055d311 100644 --- a/src/chrome/src/agent/agent.js +++ b/src/chrome/src/agent/agent.js @@ -1,4 +1,7 @@ import { AGENT_TOOLS, AGENT_TOOL_NAMES, RESERVED_AGENT_TOOL_NAMES, getToolsForMode, SYSTEM_PROMPT_ASK, SYSTEM_PROMPT_ACT, SYSTEM_PROMPT_ACT_COMPACT, SYSTEM_PROMPT_ACT_MID, SYSTEM_PROMPT_DEV_APPENDIX, SYSTEM_PROMPT_WEBMCP_ASK, SYSTEM_PROMPT_WEBMCP_ACT } from './tools.js'; +import { validateToolArguments } from './tool-arguments.js'; +import { isSessionQuotaError, serializeConversationForSession, SESSION_CONVERSATION_BUDGET_BYTES, SESSION_CONVERSATION_RETRY_BUDGET_BYTES } from './conversation-persistence.js'; +import { formatErrorMessage } from '../error-format.js'; import { handleDoneJson } from './cloud-output.js'; import { applyReadPageWindow, fitReadPageWindowResult, isReadPageWindowResult } from './read-page-window.js'; import { LoopDetector } from './loop-detector.js'; @@ -288,6 +291,9 @@ export class Agent extends LoopDetector { this._runModeOverrides = new Map(); // tabId -> effective mode for the active run only this.conversationIds = new Map(); // tabId -> stable conversationId (regenerated on clearConversation) this.submittedRunRequestIds = new Map(); // tabId -> request whose user turn is durable in storage.session + this.persistenceDegradedTabs = new Map(); // tabId -> non-durable recovery state after storage failure + this._persistenceWarningKeys = new Set(); + this._runUpdateCallbacks = new Map(); this.plannerFollowUpSkipTabs = new Set(); // tabIds allowed one short follow-up after an approved try-mode plan this.hydratedTabs = new Set(); // tabIds we've already pulled from storage this.persistTimers = new Map(); // tabId -> debounce handle @@ -845,10 +851,13 @@ export class Agent extends LoopDetector { } activeRunState(tabId) { + const persistenceState = this.persistenceDegradedTabs.get(tabId) || null; const state = { running: this._runningTabs.has(tabId), runId: this.currentRunId.get(tabId) || null, pendingPlan: null, + persistenceDegraded: !!persistenceState, + persistenceDegradedReason: persistenceState?.reason || null, }; const tabPending = this._pendingPlans.get(tabId); if (tabPending?.size) { @@ -890,6 +899,8 @@ export class Agent extends LoopDetector { return { conversationId: this.conversationIds.get(tabId) || null, sourceGrounding: selectionGrounded ? SELECTION_ONLY_SOURCE_GROUNDING : null, + persistenceDegraded: this.persistenceDegradedTabs.has(tabId), + persistenceDegradedReason: this.persistenceDegradedTabs.get(tabId)?.reason || null, }; } @@ -1418,7 +1429,7 @@ export class Agent extends LoopDetector { } _interactiveAskStreamingFailure(error) { - const rawMessage = String(error?.message || error || 'Streaming request failed.'); + const rawMessage = formatErrorMessage(error, { fallback: 'Streaming request failed.' }); const message = rawMessage .replace(/\b(Bearer)\s+[^\s,;]+/gi, '$1 [redacted]') .replace(/((?:^|[^a-zA-Z0-9_])["']?(?:api[_ -]?key|access[_ -]?token|token|secret|password)["']?\s*[:=]\s*)(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,;}\]]+)/gi, '$1[redacted]') @@ -2032,13 +2043,39 @@ export class Agent extends LoopDetector { _invalidToolArgumentsResult(fnName, parsed) { return { success: false, + invalidArguments: true, invalidToolArguments: true, + noDispatch: true, + dispatched: false, + errorCode: 'invalid_tool_arguments', error: `${fnName || 'tool'} could not run because its arguments were not valid JSON. Re-emit the same tool call with a valid JSON object for arguments; do not assume the action happened.`, detail: parsed?.error || 'invalid JSON', rawPreview: parsed?.rawPreview || '', }; } + _toolParametersForValidation(tabId, fnName, toolSchemas = null) { + const advertised = toolSchemas instanceof Map ? toolSchemas.get(fnName) : null; + if (advertised) return advertised; + const builtIn = AGENT_TOOLS.find(tool => tool.function?.name === fnName)?.function?.parameters; + if (fnName === 'done') { + return { + type: 'object', + properties: { + summary: { type: 'string' }, + outcome: { type: 'string', enum: ['success', 'partial', 'failed'] }, + result: { type: 'object' }, + }, + required: ['summary'], + }; + } + if (builtIn) return builtIn; + if (fnName === 'load_skill') { + return this._skillLoaderDefinition(this._effectiveRunMode(tabId), this._resolvePromptTier())?.function?.parameters || null; + } + return this._activeSkillToolForName(tabId, fnName)?.parameters || null; + } + _normalizeToolResult(fnName, result, outcomeUnknown = Agent.STATE_CHANGE_TOOLS.has(fnName)) { if (result != null) return result; return { @@ -2959,7 +2996,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d denied: true, noDispatch: true, unsupported: true, - error: String(error?.message || error || 'WebMCP is unavailable.'), + error: formatErrorMessage(error, { fallback: 'WebMCP is unavailable.' }), }, }; } @@ -3666,7 +3703,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d return true; } - async _executeToolBatch(tabId, toolCalls, messages, onUpdate, provider, partialAssistantText = null, allowedToolNames = AGENT_TOOL_NAMES, step = null, runOptions = {}) { + async _executeToolBatch(tabId, toolCalls, messages, onUpdate, provider, partialAssistantText = null, allowedToolNames = AGENT_TOOL_NAMES, step = null, runOptions = {}, toolSchemas = null) { let didStateChange = false; const promptTier = this._resolvePromptTier(); const completionBatchStartState = this.completionInvariants.get(tabId) || null; @@ -3745,6 +3782,18 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d const argRepair = this._repairToolCallArgs(fnName, parsedArgs.args); let fnArgs = this._toolCallArgsWithReplayMethod(tabId, fnName, argRepair.args); const argRepairNotice = argRepair.note || ''; + const parameters = this._toolParametersForValidation(tabId, fnName, toolSchemas); + const argumentValidation = parameters ? validateToolArguments(fnName, fnArgs, parameters) : { ok: true }; + if (!argumentValidation.ok) { + const result = argumentValidation.result; + onUpdate('tool_call', { name: fnName, args: fnArgs, outcomeUnknown: false }); + onUpdate('tool_result', { name: fnName, result }); + messages.push({ role: 'tool', tool_call_id: tc.id, content: JSON.stringify(result) }); + const runId = this.currentRunId.get(tabId); + if (runId) trace.recordToolCall(runId, step, { name: fnName, args: fnArgs, result, latencyMs: 0 }); + if (interruptFailedBrowserAction(toolIndex, fnName)) { navNotices.length = 0; break; } + continue; + } // Chrome-protected pages must be rejected before any helper can touch // the DOM or debugger. In particular, WebMCP preparation attaches CDP, @@ -4720,8 +4769,8 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d content: resultContent, }); if (missingResponseOutcomeUnknown && typeof runOptions?.afterConsequentialTool === 'function') { - const conversationDurable = await this._persistNow(tabId).catch(() => false); - if (conversationDurable) { + const conversationDurable = await this._persistNow(tabId); + if (conversationDurable === true || conversationDurable?.ok === true) { try { await runOptions.afterConsequentialTool({ name: fnName }); } catch {} @@ -6786,7 +6835,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d } catch (e) { /* session storage may be unavailable */ } } - _conversationStorageEntry(tabId) { + _conversationStorageEntry(tabId, options = {}) { const messages = this.conversations.get(tabId); if (!messages) return null; const conversationId = this.conversationIds.get(tabId) || null; @@ -6802,14 +6851,14 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d updatedAt: Number(clarificationGuard.updatedAt) || Date.now(), } : null; - const persistedMessages = messages.map(message => ( - message?.transientCompletionVerification === true - ? { role: 'user', content: '[Completion verification screenshot omitted from persisted history.]' } - : message - )); + const serialized = serializeConversationForSession(messages, { + maxBytes: options.maxBytes || SESSION_CONVERSATION_BUDGET_BYTES, + }); return { mode: this.conversationModes.get(tabId) || 'ask', - messages: persistedMessages, + messages: serialized.messages, + sessionSnapshotCompacted: serialized.compacted, + sessionSnapshotBytes: serialized.bytes, conversationId, submittedRunRequestId: this.submittedRunRequestIds.get(tabId) || null, progressLedger: this.progressLedgers.get(tabId) || [], @@ -6820,17 +6869,60 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d }; } + _markPersistenceDegraded(tabId, reason, error = null) { + const state = { + reason: reason || 'unavailable', + requestId: this.submittedRunRequestIds.get(tabId) || null, + runId: this.currentRunId.get(tabId) || null, + at: Date.now(), + }; + this.persistenceDegradedTabs.set(tabId, state); + // A stale snapshot must never authorize automatic replay of a + // consequential action after the background connection is lost. + this.submittedRunRequestIds.delete(tabId); + const warningKey = state.runId || state.requestId || `tab:${tabId}`; + if (!this._persistenceWarningKeys.has(warningKey)) { + this._persistenceWarningKeys.add(warningKey); + this._runUpdateCallbacks.get(tabId)?.('warning', { + code: 'persistence_degraded', + persistenceDegraded: true, + reason: state.reason, + message: 'Recovery persistence is unavailable. The live task can continue, but WebBrain will not replay actions after a connection loss; retry manually if disconnected.', + }); + } + return { + ok: false, + degraded: true, + reason: state.reason, + errorCode: 'persistence_unavailable', + error: error?.message || null, + }; + } + async _persistNow(tabId) { - if (tabId == null) return false; + if (tabId == null) return { ok: false, degraded: false, reason: 'invalid_tab' }; const existing = this.persistTimers.get(tabId); if (existing) { clearTimeout(existing); this.persistTimers.delete(tabId); } const entry = this._conversationStorageEntry(tabId); - if (!entry) return false; - await chrome.storage.session.set({ [this._convKey(tabId)]: entry }); - return true; + if (!entry) return { ok: false, degraded: false, reason: 'no_conversation' }; + try { + await chrome.storage.session.set({ [this._convKey(tabId)]: entry }); + return { ok: true, degraded: entry.sessionSnapshotCompacted === true, reason: entry.sessionSnapshotCompacted ? 'sanitized' : null }; + } catch (error) { + if (isSessionQuotaError(error)) { + const compactEntry = this._conversationStorageEntry(tabId, { maxBytes: SESSION_CONVERSATION_RETRY_BUDGET_BYTES }); + try { + await chrome.storage.session.set({ [this._convKey(tabId)]: compactEntry }); + return { ok: true, degraded: true, reason: 'quota_compacted' }; + } catch (retryError) { + return this._markPersistenceDegraded(tabId, isSessionQuotaError(retryError) ? 'quota' : 'unavailable', retryError); + } + } + return this._markPersistenceDegraded(tabId, 'unavailable', error); + } } /** @@ -6843,7 +6935,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d if (existing) clearTimeout(existing); const handle = setTimeout(() => { this.persistTimers.delete(tabId); - this._persistNow(tabId).catch(() => {}); + void this._persistNow(tabId); }, 300); this.persistTimers.set(tabId, handle); } @@ -6856,10 +6948,9 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d } const previousRequestId = this.submittedRunRequestIds.get(tabId); this.submittedRunRequestIds.set(tabId, cleanRequestId); - try { - await this._persistNow(tabId); - return true; - } catch { + const persisted = await this._persistNow(tabId); + if (persisted.ok) return true; + { if (previousRequestId) this.submittedRunRequestIds.set(tabId, previousRequestId); else this.submittedRunRequestIds.delete(tabId); return false; @@ -6870,6 +6961,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d const cleanRequestId = String(requestId || ''); if (!cleanRequestId) return false; await this._hydrate(tabId); + if (this.persistenceDegradedTabs.has(tabId)) return false; return this.submittedRunRequestIds.get(tabId) === cleanRequestId; } @@ -7781,7 +7873,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d _plannerRequestFailure(error, onUpdate, provider = null) { const detail = sanitizePlannerText( - error?.message || String(error || 'Unknown planner request error.'), + formatErrorMessage(error, { fallback: 'Unknown planner request error.' }), 500, { collapseWhitespace: true }, ); @@ -8330,7 +8422,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d { step, runOptions, currentUserMessage, priorMessageSet }, ); } catch (error) { - this._logDebug({ type: 'delivery_recovery_error', step, error: error?.message || String(error) }); + this._logDebug({ type: 'delivery_recovery_error', step, error: formatErrorMessage(error) }); } const stopped = this._consumeContextOnlyAbort(tabId, messages, onUpdate); if (stopped) return stopped; @@ -8520,7 +8612,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d status = this._isCostAllowanceError(error) ? 'cost_limit' : 'error'; finalResponse = this._isCostAllowanceError(error) ? error.message - : `I could not generate the requested response: ${error?.message || String(error)}`; + : `I could not generate the requested response: ${formatErrorMessage(error)}`; } const stopped = this._consumeContextOnlyAbort(tabId, messages, onUpdate); if (stopped) return stopped; @@ -8561,7 +8653,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d { phase: 'terminal_recovery', step, runOptions, currentUserMessage, priorMessageSet }, ); } catch (error) { - this._logDebug({ type: 'terminal_recovery_error', step, error: error?.message || String(error) }); + this._logDebug({ type: 'terminal_recovery_error', step, error: formatErrorMessage(error) }); } } const stopped = this._consumeContextOnlyAbort(tabId, messages, onUpdate); @@ -10376,6 +10468,8 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d this.completionInvariants.delete(tabId); this._captchaGateStates.delete(tabId); this._userAttachmentHandles.delete(tabId); + this._runUpdateCallbacks.delete(tabId); + if (!preserveRunGuard) this.persistenceDegradedTabs.delete(tabId); if (!preserveRunGuard) { this._runningTabs.delete(tabId); this.currentRunId.delete(tabId); @@ -10396,6 +10490,8 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d this.conversationModes.delete(tabId); this.conversationIds.delete(tabId); this.submittedRunRequestIds.delete(tabId); + this.persistenceDegradedTabs.delete(tabId); + this._runUpdateCallbacks.delete(tabId); this._lastInputTokens.delete(tabId); this._lastEstCharsAtReport.delete(tabId); this._compactCooldown.delete(tabId); @@ -12113,6 +12209,16 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d && /\b(?:refus|will not|do not proceed|unauthorized|illegal|fraud|theft|unsafe|cannot assist|can't assist)\b/i.test(text); } + _isPlannerShapedJson(content) { + const object = extractFirstJsonObject(String(content || '')); + if (!object || typeof object !== 'object' || Array.isArray(object)) return false; + const plannerKeys = ['request_kind', 'requires_state_change', 'requires_submission', 'allows_planner_shaped_result', 'confidence', 'memory', 'scheduling', 'risks', 'localized']; + const hasPlannerMetadata = plannerKeys.some(key => Object.prototype.hasOwnProperty.call(object, key)); + return hasPlannerMetadata + && (typeof object.summary === 'string' || typeof object.localized?.summary === 'string') + && (Array.isArray(object.steps) || Array.isArray(object.localized?.steps)); + } + _looksLikePlanOnlyTerminal(content, state = {}, { ignoreFuturePromise = false } = {}) { const text = String(content || '').trim(); if (!text) return false; @@ -12134,6 +12240,8 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d && String(object.mode || '').toLowerCase() !== 'inactive'; if (plannerShape || policyShape) return state.allowsPlannerShapedResult !== true; } + const runtimeModeContradiction = /\b(?:switch|change|set)\s+(?:back\s+)?to\s+act\s+mode\b|\b(?:currently|still|now)\s+(?:running\s+)?in\s+ask\s+mode\b/i.test(text); + if (runtimeModeContradiction) return true; // "Next, I will …" / "I plan to …" is agent-continue language and is always // invalid as a terminal. Bare "I will …" is evidence-gated so drafted reply // text can finish after a real task tool without a planner exemption flag. @@ -12205,8 +12313,8 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d } 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.]' - : '[Agent stopped because the model returned another plain terminal or a plan/promise instead of completing the execute protocol, even after one recovery nudge. No successful action was verified.]', + ? 'Some task tools completed, but I could not verify a valid completion after the recovery attempt. Please inspect the current page before retrying to avoid duplicate side effects.' + : 'I could not verify any requested page action after the recovery attempt, so I stopped without claiming completion. No successful action was verified, and nothing was verified as submitted or sent.', status: 'plan_only_output', }; } @@ -14725,7 +14833,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d success: false, supported: false, unsupported: true, - error: String(error?.message || error || 'WebMCP is unavailable.'), + error: formatErrorMessage(error, { fallback: 'WebMCP is unavailable.' }), hint: 'WebMCP currently requires a supporting Chrome build/page configuration. Continue with the accessibility tree or DOM tools.', }; } @@ -14796,7 +14904,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d success: false, dispatched: false, noDispatch: true, - error: `execute_webmcp_tool failed: ${error?.message || error}`, + error: `execute_webmcp_tool failed: ${formatErrorMessage(error)}`, }; } } @@ -20081,6 +20189,8 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d this._clickAxCdpFallbacks.delete(tabId); const completionRunToken = this._beginCompletionInvariant(tabId); this._runningTabs.add(tabId); + if (runOptions?.trustedContinuation !== true) this.persistenceDegradedTabs.delete(tabId); + this._runUpdateCallbacks.set(tabId, onUpdate); const previousForegroundCapture = this._configureCapturePolicyForRun(tabId, runOptions); this._runModeOverrides.set(tabId, mode); const previousCloudContext = this.cloudRunContexts.get(tabId); @@ -20102,6 +20212,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d } await this._restoreCapturePolicyAfterRun(tabId, previousForegroundCapture); this._userAttachmentHandles.delete(tabId); + this._runUpdateCallbacks.delete(tabId); this._runningTabs.delete(tabId); this._clearRunLoopState(tabId); this._clickAxCdpFallbacks.delete(tabId); @@ -20122,6 +20233,13 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d */ async _applyAttachments(enriched, attachments, provider, options = {}) { attachments = this._registerUserAttachments(options.tabId, attachments); + enriched.attachmentHandles = attachments.map(att => ({ + attachmentId: att.attachmentId, + kind: att.kind, + name: att.name || null, + mimeType: att.mimeType || null, + size: Number(att.size) || null, + })); const blocks = []; const textAttachmentCount = (attachments || []).filter(att => att?.kind === 'text').length; let textBudgetRemaining = this._textAttachmentContentBudget(provider, { ...options, enriched }); @@ -20398,6 +20516,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d // second source and defeat the selection-only boundary. if (selectionOnly) tools = []; let allowedToolNames = new Set(tools.map(t => t.function.name)); + let toolSchemas = new Map(tools.map(t => [t.function.name, t.function.parameters])); const plannerTemperature = this._isActionMode(mode) ? 0.15 : 0.3; let steps = 0; // Tracks whether we've already nudged the model after an empty @@ -20564,6 +20683,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d }); if (selectionOnly) tools = []; allowedToolNames = new Set(tools.map(t => t.function.name)); + toolSchemas = new Map(tools.map(t => [t.function.name, t.function.parameters])); // Auto-compact mid-run when the conversation outgrows the budget — not // just between user turns. Uses the previous step's reported token count, @@ -20723,14 +20843,19 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d } if (result.toolCalls && result.toolCalls.length > 0) { + const suppressPlannerContent = this._isPlannerShapedJson(result.content); + const assistantToolContent = suppressPlannerContent ? null : (result.content || null); + if (suppressPlannerContent) { + this._logDebug({ type: 'planner_shaped_content_suppressed', step: steps, toolCallCount: result.toolCalls.length }); + } messages.push(this._withResponseItems({ role: 'assistant', - content: result.content || null, + content: assistantToolContent, tool_calls: result.toolCalls, }, result.responseItems, result.reasoningContent, provider)); const batchResult = await this._executeToolBatch( - tabId, result.toolCalls, messages, onUpdate, provider, result.content, allowedToolNames, steps, runOptions + tabId, result.toolCalls, messages, onUpdate, provider, assistantToolContent, allowedToolNames, steps, runOptions, toolSchemas ); if (batchResult.action === 'return') { finalResponse = batchResult.value; @@ -20919,7 +21044,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d this._persist(tabId); return finalResponse; } catch (error) { - const message = error?.message || String(error); + const message = formatErrorMessage(error); _traceStatus = 'error'; finalResponse = `Error: ${message}`; if (runId) { @@ -20948,6 +21073,8 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d this._clickAxCdpFallbacks.delete(tabId); const completionRunToken = this._beginCompletionInvariant(tabId); this._runningTabs.add(tabId); + if (runOptions?.trustedContinuation !== true) this.persistenceDegradedTabs.delete(tabId); + this._runUpdateCallbacks.set(tabId, onUpdate); const previousForegroundCapture = this._configureCapturePolicyForRun(tabId, runOptions); this._runModeOverrides.set(tabId, mode); const previousCloudContext = this.cloudRunContexts.get(tabId); @@ -20969,6 +21096,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d } await this._restoreCapturePolicyAfterRun(tabId, previousForegroundCapture); this._userAttachmentHandles.delete(tabId); + this._runUpdateCallbacks.delete(tabId); this._runningTabs.delete(tabId); this._clearRunLoopState(tabId); this._clickAxCdpFallbacks.delete(tabId); @@ -21133,6 +21261,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d // page or network content cannot be introduced after the source anchor. if (selectionOnly) tools = []; let allowedToolNames = new Set(tools.map(t => t.function.name)); + let toolSchemas = new Map(tools.map(t => [t.function.name, t.function.parameters])); const plannerTemperature = this._isActionMode(mode) ? 0.15 : 0.3; let steps = 0; // See processMessage — used to break the empty-response→nudge cycle. @@ -21175,6 +21304,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d }); if (selectionOnly) tools = []; allowedToolNames = new Set(tools.map(t => t.function.name)); + toolSchemas = new Map(tools.map(t => [t.function.name, t.function.parameters])); // Auto-compact mid-run when the conversation outgrows the budget. The // streaming path doesn't get a per-call token count, so this leans on @@ -21272,6 +21402,12 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d return finish(costStopMessage, 'cost_limit'); } const toolCalls = Object.values(toolCallsAccumulator); + const suppressPlannerContent = this._isPlannerShapedJson(fullText); + if (suppressPlannerContent) { + this._logDebug({ type: 'planner_shaped_content_suppressed', step: steps, toolCallCount: toolCalls.length }); + fullText = ''; + onUpdate('text', { content: '', replace: true }); + } this._logDebug({ type: 'llm_stream_response', step: steps, content: fullText, toolCalls }); messages.push(this._withResponseItems({ role: 'assistant', @@ -21280,7 +21416,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d }, responseItems, reasoningContent, provider)); const batchResult = await this._executeToolBatch( - tabId, toolCalls, messages, onUpdate, provider, fullText, allowedToolNames, steps, runOptions + tabId, toolCalls, messages, onUpdate, provider, fullText, allowedToolNames, steps, runOptions, toolSchemas ); if (batchResult.action === 'return') { if (batchResult.status) { @@ -21446,7 +21582,8 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d return finish(fullText); } catch (e) { - this._logDebug({ type: 'llm_stream_error', step: steps, error: e.message }); + const caughtMessage = formatErrorMessage(e); + this._logDebug({ type: 'llm_stream_error', step: steps, error: caughtMessage }); // If context overflow, trim and retry if (this._isContextOverflow(e.message)) { onUpdate('thinking', { step: steps, note: 'Context too large, trimming...' }); @@ -21454,8 +21591,8 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d this._persist(tabId); continue; // retry the loop with trimmed context } - onUpdate('error', { message: e.message }); - const errMsg = `Error: ${e.message}`; + onUpdate('error', { message: caughtMessage }); + const errMsg = `Error: ${caughtMessage}`; messages.push({ role: 'assistant', content: errMsg }); this._persist(tabId); return finish(errMsg, 'error'); @@ -21472,7 +21609,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d onUpdate('text', { content: summary }); return finish(summary, 'max_steps'); } catch (error) { - const message = error?.message || String(error); + const message = formatErrorMessage(error); _traceStatus = 'error'; finalResponse = `Error: ${message}`; if (runId) trace.recordError(runId, null, 'agent', message); diff --git a/src/chrome/src/agent/conversation-persistence.js b/src/chrome/src/agent/conversation-persistence.js new file mode 100644 index 000000000..ee60ad90b --- /dev/null +++ b/src/chrome/src/agent/conversation-persistence.js @@ -0,0 +1,123 @@ +export const SESSION_CONVERSATION_BUDGET_BYTES = 1_500_000; +export const SESSION_CONVERSATION_RETRY_BUDGET_BYTES = 450_000; + +const DATA_URL_RE = /data:(?:image|application)\/[a-zA-Z0-9+.-]+(?:;[^,\s]*)?;base64,[A-Za-z0-9+/=\s]+/g; + +function byteLength(value) { + return new TextEncoder().encode(JSON.stringify(value)).byteLength; +} + +function capText(value, maxChars, marker, state) { + const sanitized = String(value || '').replace(DATA_URL_RE, () => { + state.compacted = true; + return '[embedded binary data omitted from session recovery]'; + }); + if (sanitized.length <= maxChars) return sanitized; + state.compacted = true; + return `${sanitized.slice(0, Math.max(0, maxChars - marker.length - 1))}\n${marker}`; +} + +function attachmentPlaceholder(message, kind) { + const handles = Array.isArray(message?.attachmentHandles) ? message.attachmentHandles : []; + if (handles.length) { + const ids = handles.map(handle => String(handle?.attachmentId || '')).filter(Boolean).slice(0, 8); + return `[User ${kind} attachment bytes omitted from session recovery; durable attachment handle(s): ${ids.join(', ') || 'available in chat history'}.]`; + } + return `[${kind === 'image' ? 'Screenshot/image' : 'Document'} bytes omitted from session recovery.]`; +} + +function sanitizeValue(value, state, depth = 0) { + if (typeof value === 'string') return capText(value, 32_000, '[large value truncated for session recovery]', state); + if (!value || typeof value !== 'object' || depth > 6) return value; + if (Array.isArray(value)) return value.slice(0, 100).map(item => sanitizeValue(item, state, depth + 1)); + const out = {}; + for (const [key, child] of Object.entries(value).slice(0, 100)) { + if (typeof child === 'string' && (/^(?:data|url)$/i.test(key)) && /^data:.*;base64,/i.test(child)) { + state.compacted = true; + out[key] = '[embedded binary data omitted from session recovery]'; + } else { + out[key] = sanitizeValue(child, state, depth + 1); + } + } + return out; +} + +function sanitizeContent(message, state, caps) { + if (message?.transientCompletionVerification === true) { + state.compacted = true; + return '[Completion verification screenshot omitted from persisted history.]'; + } + const content = message?.content; + if (typeof content === 'string') { + const cap = message.role === 'tool' ? caps.toolChars : caps.textChars; + return capText(content, cap, '[large content truncated for session recovery]', state); + } + if (!Array.isArray(content)) return sanitizeValue(content, state); + return content.slice(0, 100).map(block => { + if (block?.type === 'image_url' || block?.type === 'image') { + state.compacted = true; + return { type: 'text', text: attachmentPlaceholder(message, 'image') }; + } + if (block?.type === 'document' || block?.source?.type === 'base64') { + state.compacted = true; + return { type: 'text', text: attachmentPlaceholder(message, 'document') }; + } + return sanitizeValue(block, state); + }); +} + +function sanitizeMessage(message, state, caps) { + if (!message || typeof message !== 'object') return message; + const out = { ...message, content: sanitizeContent(message, state, caps) }; + if (Array.isArray(message.tool_calls)) { + out.tool_calls = message.tool_calls.slice(0, 50).map(call => ({ + ...call, + function: call?.function ? { + ...call.function, + arguments: capText(call.function.arguments || '', caps.toolArgsChars, '[tool arguments truncated for session recovery]', state), + } : call?.function, + })); + } + if (Array.isArray(message.responseItems)) out.responseItems = sanitizeValue(message.responseItems, state); + return out; +} + +function reduceToBudget(messages, maxBytes, state) { + if (byteLength(messages) <= maxBytes) return messages; + const out = messages.map(message => ({ ...message })); + const keepRecentFrom = Math.max(1, out.length - 14); + for (let index = 1; index < keepRecentFrom && byteLength(out) > maxBytes; index++) { + const message = out[index]; + if (!message || message.role === 'system') continue; + state.compacted = true; + out[index] = { + role: message.role, + ...(message.tool_call_id ? { tool_call_id: message.tool_call_id } : {}), + content: '[Earlier message omitted from bounded session recovery snapshot.]', + }; + } + for (let index = keepRecentFrom; index < out.length && byteLength(out) > maxBytes; index++) { + const message = out[index]; + if (!message || typeof message.content !== 'string' || message.content.length <= 4_000) continue; + state.compacted = true; + out[index] = { ...message, content: `${message.content.slice(0, 3_900)}\n[content truncated for session recovery]` }; + } + return out; +} + +export function serializeConversationForSession(messages, options = {}) { + const maxBytes = Number.isFinite(options.maxBytes) ? Math.max(100_000, options.maxBytes) : SESSION_CONVERSATION_BUDGET_BYTES; + const tight = maxBytes <= SESSION_CONVERSATION_RETRY_BUDGET_BYTES; + const caps = tight + ? { textChars: 16_000, toolChars: 8_000, toolArgsChars: 8_000 } + : { textChars: 96_000, toolChars: 32_000, toolArgsChars: 24_000 }; + const state = { compacted: false }; + const sanitized = Array.isArray(messages) ? messages.map(message => sanitizeMessage(message, state, caps)) : []; + const bounded = reduceToBudget(sanitized, maxBytes, state); + return { messages: bounded, bytes: byteLength(bounded), compacted: state.compacted }; +} + +export function isSessionQuotaError(error) { + const message = String(error?.message || error || ''); + return /quota|QUOTA_BYTES|bytes? exceeded|storage limit/i.test(message); +} diff --git a/src/chrome/src/agent/planner.js b/src/chrome/src/agent/planner.js index fd58f0e8b..3eb1be39d 100644 --- a/src/chrome/src/agent/planner.js +++ b/src/chrome/src/agent/planner.js @@ -114,10 +114,13 @@ Rules: - Classify the user's semantic intent across any language; never rely on literal keywords or UI labels. - execute means the user authorizes action. A request to plan and then perform is execute. - 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. +- Runtime mode does not force execute. In Act mode, an advice, explanation, or drafting follow-up is still respond when trusted conversation context already contains everything needed. +- Require execute only when the answer genuinely needs fresh page, browser, or network evidence. Do not reread a page merely because Act mode is selected. - 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. +- When a required form value is unavailable from trusted or public evidence, leave the field untouched and classify as clarify. Never plan to focus, clear, or write an empty value as a stand-in for missing personal information. - 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/agent/tool-arguments.js b/src/chrome/src/agent/tool-arguments.js new file mode 100644 index 000000000..2f10d75e5 --- /dev/null +++ b/src/chrome/src/agent/tool-arguments.js @@ -0,0 +1,126 @@ +function isPlainObject(value) { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function valueMatchesType(value, type) { + if (type === 'object') return isPlainObject(value); + if (type === 'array') return Array.isArray(value); + if (type === 'integer') return Number.isInteger(value); + if (type === 'number') return typeof value === 'number' && Number.isFinite(value); + if (type === 'null') return value === null; + return typeof value === type; +} + +function validationFailure(toolName, invalidArguments, detail) { + const fields = [...new Set(invalidArguments.map(String))]; + return { + ok: false, + result: { + success: false, + invalidArguments: true, + invalidToolArguments: true, + noDispatch: true, + dispatched: false, + errorCode: 'invalid_tool_arguments', + invalidArgumentNames: fields, + error: `${toolName || 'Tool'} could not run because its arguments do not match the advertised schema. Re-emit the call with only declared, valid arguments; do not assume the action happened.`, + detail, + }, + }; +} + +function validateValue(value, schema, path, failures) { + if (!schema || typeof schema !== 'object') return; + const acceptedTypes = Array.isArray(schema.type) ? schema.type : (schema.type ? [schema.type] : []); + if (acceptedTypes.length && !acceptedTypes.some(type => valueMatchesType(value, type))) { + failures.push(path); + return; + } + if (Array.isArray(schema.enum) && !schema.enum.some(candidate => Object.is(candidate, value))) { + failures.push(path); + return; + } + if (typeof value === 'string') { + if (Number.isFinite(schema.minLength) && value.length < schema.minLength) failures.push(path); + if (Number.isFinite(schema.maxLength) && value.length > schema.maxLength) failures.push(path); + } + if (Array.isArray(value) && schema.items) { + value.forEach((item, index) => validateValue(item, schema.items, `${path}[${index}]`, failures)); + } + if (!isPlainObject(value)) return; + const properties = isPlainObject(schema.properties) ? schema.properties : {}; + for (const required of Array.isArray(schema.required) ? schema.required : []) { + if (!Object.prototype.hasOwnProperty.call(value, required)) failures.push(`${path}.${required}`); + } + if (schema.additionalProperties !== true && typeof schema.additionalProperties !== 'object') { + for (const key of Object.keys(value)) { + if (!Object.prototype.hasOwnProperty.call(properties, key)) failures.push(`${path}.${key}`); + } + } + for (const [key, child] of Object.entries(value)) { + if (Object.prototype.hasOwnProperty.call(properties, key)) { + validateValue(child, properties[key], `${path}.${key}`, failures); + } + } +} + +function validateClickTarget(args) { + const text = typeof args.text === 'string' && args.text.trim() !== ''; + const selector = typeof args.selector === 'string' && args.selector.trim() !== ''; + const index = Number.isInteger(args.index) && args.index >= 0; + const hasX = typeof args.x === 'number' && Number.isFinite(args.x); + const hasY = typeof args.y === 'number' && Number.isFinite(args.y); + const coordinates = hasX && hasY && !(args.x === 0 && args.y === 0); + const strategies = [text, selector, index, coordinates].filter(Boolean).length; + const invalidCoordinates = hasX !== hasY || ((hasX && hasY) && args.x === 0 && args.y === 0); + if (strategies !== 1 || invalidCoordinates || (args.from_screenshot === true && !coordinates)) { + return validationFailure('click', ['target'], 'Provide exactly one target strategy: non-empty text, non-empty selector, a non-negative integer index, or a complete non-zero x/y coordinate pair.'); + } + return null; +} + +export function closeToolDefinition(tool) { + if (!tool?.function) return tool; + const parameters = tool.function.parameters; + if (!isPlainObject(parameters)) return tool; + const closeSchema = (schema) => { + if (!isPlainObject(schema)) return schema; + const closed = { ...schema }; + if (isPlainObject(schema.properties)) { + closed.properties = Object.fromEntries(Object.entries(schema.properties).map(([key, child]) => [key, closeSchema(child)])); + } + if (schema.items) closed.items = closeSchema(schema.items); + if (schema.type === 'object' && schema.additionalProperties === undefined) closed.additionalProperties = false; + return closed; + }; + return { + ...tool, + function: { + ...tool.function, + parameters: closeSchema(parameters), + }, + }; +} + +export function closeToolDefinitions(tools) { + return Array.isArray(tools) ? tools.map(closeToolDefinition) : []; +} + +export function validateToolArguments(toolName, args, parameters) { + if (!isPlainObject(args)) { + return validationFailure(toolName, ['$'], 'Arguments must be a JSON object.'); + } + const closedParameters = isPlainObject(parameters) + ? { ...parameters, additionalProperties: false } + : { type: 'object', properties: {}, additionalProperties: false }; + const failures = []; + validateValue(args, closedParameters, '$', failures); + if (failures.length) { + return validationFailure(toolName, failures, `Invalid or undeclared argument(s): ${[...new Set(failures)].join(', ')}.`); + } + if (toolName === 'click') { + const clickFailure = validateClickTarget(args); + if (clickFailure) return clickFailure; + } + return { ok: true, args }; +} diff --git a/src/chrome/src/agent/tools.js b/src/chrome/src/agent/tools.js index 89f88ec8e..4f971f75b 100644 --- a/src/chrome/src/agent/tools.js +++ b/src/chrome/src/agent/tools.js @@ -1,3 +1,5 @@ +import { closeToolDefinitions } from './tool-arguments.js'; + /** * Tool definitions for the WebBrain agent. * These are sent to the LLM in OpenAI function-calling format. @@ -1316,15 +1318,15 @@ export function getToolsForMode(mode, opts = {}) { base = [...base, ...extras]; } const useDoneJson = normalizedMode === 'act' && tier === 'full' && opts.cloudRun === true && !!opts.outputSchema; - if (useDoneJson) return base.map(tool => (tool.function.name === 'done' ? DONE_JSON_TOOL : tool)); + if (useDoneJson) return closeToolDefinitions(base.map(tool => (tool.function.name === 'done' ? DONE_JSON_TOOL : tool))); const useOutcomeDone = normalizedMode !== 'ask'; - if (!opts.strictSecretMode && !useOutcomeDone) return base; + if (!opts.strictSecretMode && !useOutcomeDone) return closeToolDefinitions(base); const replacement = opts.strictSecretMode ? (useOutcomeDone ? (tier === 'compact' ? DONE_TOOL_COMPACT_STRICT_WITH_OUTCOME : DONE_TOOL_STRICT_WITH_OUTCOME) : DONE_TOOL_STRICT) : (tier === 'compact' ? DONE_TOOL_COMPACT_WITH_OUTCOME : DONE_TOOL_WITH_OUTCOME); - return base.map(t => (t.function.name === 'done' ? replacement : t)); + return closeToolDefinitions(base.map(t => (t.function.name === 'done' ? replacement : t))); } const SENSITIVE_PAGE_DATA_GUIDANCE = `SENSITIVE PAGE DATA: @@ -1333,7 +1335,9 @@ const SENSITIVE_PAGE_DATA_GUIDANCE = `SENSITIVE PAGE DATA: const PLAN_TO_EXECUTION_GUIDANCE = `PLAN TO EXECUTION: - In Act/Dev, an approved or pinned plan is context for doing the task, not a completed user outcome. When the user authorized action, do not end by returning the plan, planner JSON, action-policy metadata, or a promise to act; call the first permitted tool and continue until done, an explicit blocker, cancellation, or required user input. +- The trusted runtime mode is authoritative. Never claim that the run is in Ask mode or tell the user to switch to Act when the runtime prompt says Act/Dev. - Do not call done with the plan, planner JSON, action-policy metadata, or a promise to act as its summary. Call a permitted non-done tool first; use clarify or stop only for a real blocker or required user input. +- If a required form value is unavailable, leave that field untouched and call clarify. Never focus, clear, or write an empty value merely because the value is unknown. - Respect user boundaries: if the user asked only for a plan, or said to wait for approval or confirmation, return the plan or wait and do not execute. - Structured output can be legitimate user-requested data. Honor requested JSON or markdown formats; never treat an answer as leaked planner metadata merely because it looks like a plan or policy.`; diff --git a/src/chrome/src/background.js b/src/chrome/src/background.js index 389125f1a..f85468983 100644 --- a/src/chrome/src/background.js +++ b/src/chrome/src/background.js @@ -49,7 +49,7 @@ import { } from './recorder/host.js'; import { RUN_CAPTURE_START_ERROR_PREFIX, createRunCaptureController } from './run-capture.js'; import { normalizeOllamaLaunchHandoff } from './ollama-handoff.js'; -import { RunUiJournal, RunUiPersistenceScheduler, runUiSnapshotForRequest } from './run-ui-journal.js'; +import { RunUiJournal, RunUiPersistenceScheduler, compactRunUiSnapshotForPersist, runUiSnapshotForRequest } from './run-ui-journal.js'; import { USER_MEMORY_AUTO_CAPTURE_KEY, USER_MEMORY_ENABLED_KEY, @@ -1350,7 +1350,7 @@ function cloneRunUiSnapshot(snapshot) { function persistRunUiSnapshot(tabId, snapshot) { const requestId = String(snapshot?.requestId || ''); if (runUiPersistenceFailures.get(tabId) === requestId) return Promise.resolve(false); - const stableSnapshot = cloneRunUiSnapshot(snapshot); + const stableSnapshot = compactRunUiSnapshotForPersist(cloneRunUiSnapshot(snapshot)); const previous = runUiPersistenceQueues.get(tabId) || Promise.resolve(true); const write = previous.catch(() => false).then(async () => { if (runUiPersistenceFailures.get(tabId) === requestId) return false; @@ -1358,9 +1358,15 @@ function persistRunUiSnapshot(tabId, snapshot) { await chrome.storage.session?.set({ [RUN_UI_PREFIX + tabId]: stableSnapshot }); return true; } catch { - runUiPersistenceFailures.set(tabId, requestId); - try { await chrome.storage.session?.remove(RUN_UI_PREFIX + tabId); } catch {} - return false; + try { + await chrome.storage.session?.set({ + [RUN_UI_PREFIX + tabId]: compactRunUiSnapshotForPersist(stableSnapshot, { tight: true }), + }); + return true; + } catch { + runUiPersistenceFailures.set(tabId, requestId); + return false; + } } }); runUiPersistenceQueues.set(tabId, write); @@ -2570,15 +2576,20 @@ async function handleMessage(msg, sender) { || (durabilityRequestId ? await agent.hasDurableSubmittedTurn(tabId, durabilityRequestId) : false); + const conversationState = await agent.getConversationState(tabId); + const activeState = agent.activeRunState(tabId); + const runUiDurable = !runUiSnapshot + || runUiPersistenceFailures.get(tabId) !== String(runUiSnapshot.requestId || ''); return { ok: true, - ...(await agent.getConversationState(tabId)), - ...agent.activeRunState(tabId), + ...conversationState, + ...activeState, starting: !!starting, startingRequestId: starting?.requestId || null, submittedTurnDurable, - runUiDurable: !runUiSnapshot - || runUiPersistenceFailures.get(tabId) !== String(runUiSnapshot.requestId || ''), + runUiDurable: runUiDurable, + persistenceDegraded: activeState.persistenceDegraded === true || !runUiDurable, + persistenceDegradedReason: activeState.persistenceDegradedReason || (!runUiDurable ? 'run_ui' : null), detachedError, runUi: requestedRunUi, }; diff --git a/src/chrome/src/error-format.js b/src/chrome/src/error-format.js new file mode 100644 index 000000000..00c2d411d --- /dev/null +++ b/src/chrome/src/error-format.js @@ -0,0 +1,55 @@ +const DEFAULT_ERROR_MESSAGE = 'An unexpected error occurred.'; +const PREFERRED_KEYS = ['message', 'error', 'detail', 'reason', 'description', 'cause', 'code', 'errorCode']; + +function bounded(text, maxLength) { + const value = String(text || '').trim(); + if (value === '[object Object]') return DEFAULT_ERROR_MESSAGE; + if (value.length <= maxLength) return value; + return `${value.slice(0, Math.max(0, maxLength - 14))}… [truncated]`; +} + +function stableJson(value, maxDepth = 4) { + const seen = new WeakSet(); + const visit = (item, depth) => { + if (item == null || typeof item !== 'object') return item; + if (seen.has(item)) return '[circular]'; + if (depth >= maxDepth) return Array.isArray(item) ? '[array omitted]' : '[object omitted]'; + seen.add(item); + if (Array.isArray(item)) return item.slice(0, 20).map(entry => visit(entry, depth + 1)); + const out = {}; + for (const key of Object.keys(item).sort().slice(0, 30)) out[key] = visit(item[key], depth + 1); + return out; + }; + try { + return JSON.stringify(visit(value, 0)); + } catch { + return ''; + } +} + +export function formatErrorMessage(value, options = {}) { + const maxLength = Number.isFinite(options.maxLength) ? Math.max(80, options.maxLength) : 2000; + const fallback = bounded(options.fallback || DEFAULT_ERROR_MESSAGE, maxLength) || DEFAULT_ERROR_MESSAGE; + const seen = new WeakSet(); + const find = (item, depth = 0) => { + if (typeof item === 'string' || typeof item === 'number' || typeof item === 'boolean') { + const text = bounded(item, maxLength); + return text && text !== '[object Object]' ? text : ''; + } + if (!item || typeof item !== 'object' || depth > 5 || seen.has(item)) return ''; + seen.add(item); + for (const key of PREFERRED_KEYS) { + if (!Object.prototype.hasOwnProperty.call(item, key)) continue; + const found = find(item[key], depth + 1); + if (found) return found; + } + return ''; + }; + const preferred = find(value); + if (preferred) return preferred; + if (value && typeof value === 'object') { + const json = bounded(stableJson(value), maxLength); + if (json && json !== '{}' && json !== '[]') return json; + } + return fallback; +} diff --git a/src/chrome/src/run-ui-journal.js b/src/chrome/src/run-ui-journal.js index 4ee28d21d..2e8daff3f 100644 --- a/src/chrome/src/run-ui-journal.js +++ b/src/chrome/src/run-ui-journal.js @@ -1,6 +1,8 @@ export const RUN_UI_EVENT_LIMIT = 256; export const RUN_UI_TEXT_DELTA_PERSIST_DELAY_MS = 200; export const RUN_UI_STREAM_TEXT_LIMIT = 100000; +export const RUN_UI_PERSIST_BUDGET = 512 * 1024; +export const RUN_UI_PERSIST_RETRY_BUDGET = 128 * 1024; /** * Highest sequence number that was genuinely evicted from the bounded replay @@ -62,6 +64,35 @@ export function compactRunUiData(type, data) { return data; } +export function compactRunUiSnapshotForPersist(snapshot, options = {}) { + const tight = options.tight === true; + const budget = tight ? RUN_UI_PERSIST_RETRY_BUDGET : RUN_UI_PERSIST_BUDGET; + const clone = typeof structuredClone === 'function' + ? structuredClone(snapshot || {}) + : JSON.parse(JSON.stringify(snapshot || {})); + clone.finalContent = String(clone.finalContent || '').slice(0, tight ? 8000 : 30000); + clone.streamedText = String(clone.streamedText || '').slice(0, tight ? 30000 : RUN_UI_STREAM_TEXT_LIMIT); + const eventCap = tight ? 64 : RUN_UI_EVENT_LIMIT; + clone.events = (Array.isArray(clone.events) ? clone.events : []).slice(-eventCap).map(event => { + const data = compactRunUiData(event?.type, event?.data); + if (tight && data && typeof data === 'object' && typeof data.content === 'string') { + data.content = data.content.slice(0, 4000); + } + return { ...event, data }; + }); + const removedBoundary = Number((Array.isArray(snapshot?.events) ? snapshot.events : []).at(-(clone.events.length + 1))?.seq || 0); + if (removedBoundary > 0) { + clone.discardedBeforeSeq = Math.max(runUiDiscardedBeforeSeq(clone), removedBoundary); + clone.truncatedBeforeSeq = clone.discardedBeforeSeq; + } + while (clone.events.length && JSON.stringify(clone).length > budget) { + const removed = clone.events.shift(); + clone.discardedBeforeSeq = Math.max(runUiDiscardedBeforeSeq(clone), Number(removed?.seq || 0)); + clone.truncatedBeforeSeq = clone.discardedBeforeSeq; + } + return clone; +} + export class RunUiPersistenceScheduler { constructor({ persist, diff --git a/src/chrome/src/trace/recorder.js b/src/chrome/src/trace/recorder.js index 58e7f7abf..230fe0acd 100644 --- a/src/chrome/src/trace/recorder.js +++ b/src/chrome/src/trace/recorder.js @@ -1,4 +1,5 @@ import { normalizeRuntimeTraceConfig } from './runtime-config.js'; +import { formatErrorMessage } from '../error-format.js'; /** * Trace recorder — writes per-run traces (LLM requests/responses, tool calls, @@ -248,7 +249,7 @@ export async function recordScreenshot(runId, step, dataUrl, caption = '') { } export function recordError(runId, step, phase, message) { - return _appendEvent(runId, 'error', { step, phase, message }); + return _appendEvent(runId, 'error', { step, phase, message: formatErrorMessage(message) }); } /** diff --git a/src/chrome/src/ui/locales/ar.js b/src/chrome/src/ui/locales/ar.js index a8ee6b08a..83fec780c 100644 --- a/src/chrome/src/ui/locales/ar.js +++ b/src/chrome/src/ui/locales/ar.js @@ -153,6 +153,8 @@ export default { 'sp.copy': 'نسخ', 'sp.copied': 'تم النسخ!', 'sp.copy.code.title': 'نسخ الكود', + 'sp.copy.message.title': 'نسخ الرسالة', + 'sp.persistence.unavailable': 'تعذّر حفظ بيانات الاسترداد. يمكن متابعة المهمة الحالية، لكن لن تُعاد الإجراءات بعد انقطاع الاتصال. أعد المحاولة يدويًا.', 'sp.error_prefix': 'خطأ: {msg}', 'sp.subscribe.allowance_used': 'تم استخدام الحصة اليومية المجانية من WebBrain Cloud.', diff --git a/src/chrome/src/ui/locales/bn.js b/src/chrome/src/ui/locales/bn.js index 387d740f4..b7e141191 100644 --- a/src/chrome/src/ui/locales/bn.js +++ b/src/chrome/src/ui/locales/bn.js @@ -255,6 +255,8 @@ export default { 'sp.copy': "কপি", 'sp.copied': "কপি করা !", 'sp.copy.code.title': "কোড কপি করুন", + 'sp.copy.message.title': "বার্তা কপি করুন", + 'sp.persistence.unavailable': "পুনরুদ্ধার ডেটা সংরক্ষণ করা যাচ্ছে না। চলমান কাজটি চালিয়ে যেতে পারে, তবে সংযোগ বিচ্ছিন্ন হলে কাজগুলো আবার চালানো হবে না। তখন নিজে পুনরায় চেষ্টা করুন।", 'sp.retry': "আবার চেষ্টা করুন", 'sp.retry.busy': "পুনরায় চেষ্টা করার আগে বর্তমান রান শেষ হওয়ার জন্য অপেক্ষা করুন।", 'sp.retry.attachments_unavailable': "ব্যর্থ প্রচেষ্টা থেকে সংযুক্তি আর উপলব্ধ নেই; শুধুমাত্র টেক্সট পুনরায় চেষ্টা.", diff --git a/src/chrome/src/ui/locales/de.js b/src/chrome/src/ui/locales/de.js index 37be91d51..6ca51d635 100644 --- a/src/chrome/src/ui/locales/de.js +++ b/src/chrome/src/ui/locales/de.js @@ -265,6 +265,8 @@ export default { 'sp.copy': 'Kopieren', 'sp.copied': 'Kopiert!', 'sp.copy.code.title': 'Code kopieren', + 'sp.copy.message.title': 'Nachricht kopieren', + 'sp.persistence.unavailable': 'Wiederherstellungsdaten können nicht gespeichert werden. Die laufende Aufgabe kann fortfahren, Aktionen werden nach einem Verbindungsabbruch jedoch nicht wiederholt. Versuche es dann manuell erneut.', 'sp.retry': 'Erneut versuchen', 'sp.retry.busy': 'Warten Sie, bis der aktuelle Durchlauf abgeschlossen ist, bevor Sie es erneut versuchen.', 'sp.retry.attachments_unavailable': 'Anhänge vom fehlgeschlagenen Versuch sind nicht mehr verfügbar; nur der Text wird erneut versucht.', diff --git a/src/chrome/src/ui/locales/en.js b/src/chrome/src/ui/locales/en.js index 7e5a1d3d3..1c11217ce 100644 --- a/src/chrome/src/ui/locales/en.js +++ b/src/chrome/src/ui/locales/en.js @@ -255,6 +255,8 @@ export default { 'sp.copy': 'Copy', 'sp.copied': 'Copied!', 'sp.copy.code.title': 'Copy code', + 'sp.copy.message.title': 'Copy message', + 'sp.persistence.unavailable': 'Recovery persistence is unavailable. The live task can continue, but actions will not be replayed after a connection loss. Retry manually if disconnected.', 'sp.retry': 'Retry', 'sp.retry.busy': 'Wait for the current run to finish before retrying.', 'sp.retry.attachments_unavailable': 'Attachments from the failed attempt are no longer available; retrying the text only.', diff --git a/src/chrome/src/ui/locales/es.js b/src/chrome/src/ui/locales/es.js index c95ddd16e..04cbc8752 100644 --- a/src/chrome/src/ui/locales/es.js +++ b/src/chrome/src/ui/locales/es.js @@ -153,6 +153,8 @@ export default { 'sp.copy': 'Copiar', 'sp.copied': '¡Copiado!', 'sp.copy.code.title': 'Copiar código', + 'sp.copy.message.title': 'Copiar mensaje', + 'sp.persistence.unavailable': 'No se pueden guardar los datos de recuperación. La tarea activa puede continuar, pero las acciones no se repetirán tras una desconexión. Vuelve a intentarlo manualmente.', 'sp.error_prefix': 'Error: {msg}', 'sp.subscribe.allowance_used': 'Se agotó la asignación diaria gratuita de WebBrain Cloud.', diff --git a/src/chrome/src/ui/locales/fa.js b/src/chrome/src/ui/locales/fa.js index d00ba8c0e..d8b655886 100644 --- a/src/chrome/src/ui/locales/fa.js +++ b/src/chrome/src/ui/locales/fa.js @@ -255,6 +255,8 @@ export default { 'sp.copy': "کپی کنید", 'sp.copied': "کپی شده!", 'sp.copy.code.title': "کد را کپی کنید", + 'sp.copy.message.title': "پیام را کپی کنید", + 'sp.persistence.unavailable': "ذخیره‌سازی داده‌های بازیابی ممکن نیست. کار زنده می‌تواند ادامه یابد، اما پس از قطع اتصال هیچ عملی دوباره اجرا نمی‌شود. در آن صورت دستی دوباره تلاش کنید.", 'sp.retry': "دوباره امتحان کنید", 'sp.retry.busy': "قبل از تلاش مجدد منتظر بمانید تا اجرای فعلی به پایان برسد.", 'sp.retry.attachments_unavailable': "پیوست‌های حاصل از تلاش ناموفق دیگر در دسترس نیستند. فقط متن را دوباره امتحان کنید", diff --git a/src/chrome/src/ui/locales/fr.js b/src/chrome/src/ui/locales/fr.js index 26f6f4df5..90233432c 100644 --- a/src/chrome/src/ui/locales/fr.js +++ b/src/chrome/src/ui/locales/fr.js @@ -153,6 +153,8 @@ export default { 'sp.copy': 'Copier', 'sp.copied': 'Copié !', 'sp.copy.code.title': 'Copier le code', + 'sp.copy.message.title': 'Copier le message', + 'sp.persistence.unavailable': 'Les données de récupération ne peuvent pas être enregistrées. La tâche en cours peut continuer, mais aucune action ne sera rejouée après une déconnexion. Réessayez manuellement.', 'sp.error_prefix': 'Erreur : {msg}', 'sp.subscribe.allowance_used': 'Quota quotidien gratuit de WebBrain Cloud épuisé.', diff --git a/src/chrome/src/ui/locales/he.js b/src/chrome/src/ui/locales/he.js index aacd31c0f..2e94189c2 100644 --- a/src/chrome/src/ui/locales/he.js +++ b/src/chrome/src/ui/locales/he.js @@ -240,6 +240,8 @@ export default { "sp.copy": "העתק", "sp.copied": "הועתק!", "sp.copy.code.title": "העתק קוד", + "sp.copy.message.title": "העתק הודעה", + "sp.persistence.unavailable": "לא ניתן לשמור נתוני שחזור. המשימה הפעילה יכולה להמשיך, אך פעולות לא יופעלו מחדש לאחר ניתוק. במקרה כזה יש לנסות שוב ידנית.", "sp.retry": "נסה שוב", "sp.retry.busy": "המתן עד שהריצה הנוכחית תסתיים לפני שתנסה שוב.", "sp.retry.attachments_unavailable": "קבצים מצורפים מהניסיון הכושל אינם זמינים עוד; מנסה שוב את הטקסט בלבד.", diff --git a/src/chrome/src/ui/locales/hi.js b/src/chrome/src/ui/locales/hi.js index 32aed9c91..3b60f500c 100644 --- a/src/chrome/src/ui/locales/hi.js +++ b/src/chrome/src/ui/locales/hi.js @@ -255,6 +255,8 @@ export default { 'sp.copy': "प्रतिलिपि", 'sp.copied': "नकल की गई!", 'sp.copy.code.title': "कोड कॉपी करें", + 'sp.copy.message.title': "संदेश कॉपी करें", + 'sp.persistence.unavailable': "पुनर्प्राप्ति डेटा सहेजा नहीं जा सकता। चालू कार्य जारी रह सकता है, लेकिन कनेक्शन टूटने के बाद कार्रवाइयाँ दोबारा नहीं चलेंगी। तब मैन्युअल रूप से पुनः प्रयास करें।", 'sp.retry': "पुनः प्रयास करें", 'sp.retry.busy': "पुनः प्रयास करने से पहले वर्तमान रन समाप्त होने तक प्रतीक्षा करें।", 'sp.retry.attachments_unavailable': "असफल प्रयास के अनुलग्नक अब उपलब्ध नहीं हैं; केवल पाठ को पुनः प्रयास करना।", diff --git a/src/chrome/src/ui/locales/id.js b/src/chrome/src/ui/locales/id.js index 143531907..df0e3e147 100644 --- a/src/chrome/src/ui/locales/id.js +++ b/src/chrome/src/ui/locales/id.js @@ -153,6 +153,8 @@ export default { 'sp.copy': 'Salin', 'sp.copied': 'Tersalin!', 'sp.copy.code.title': 'Salin kode', + 'sp.copy.message.title': 'Salin pesan', + 'sp.persistence.unavailable': 'Data pemulihan tidak dapat disimpan. Tugas aktif dapat berlanjut, tetapi tindakan tidak akan diputar ulang setelah koneksi terputus. Coba lagi secara manual.', 'sp.error_prefix': 'Galat: {msg}', 'sp.subscribe.allowance_used': 'Kuota harian gratis WebBrain Cloud telah habis.', diff --git a/src/chrome/src/ui/locales/ja.js b/src/chrome/src/ui/locales/ja.js index 884462ba1..13a929ccd 100644 --- a/src/chrome/src/ui/locales/ja.js +++ b/src/chrome/src/ui/locales/ja.js @@ -153,6 +153,8 @@ export default { 'sp.copy': 'コピー', 'sp.copied': 'コピーしました!', 'sp.copy.code.title': 'コードをコピー', + 'sp.copy.message.title': 'メッセージをコピー', + 'sp.persistence.unavailable': '復旧データを保存できません。実行中のタスクは続行できますが、接続が切れた後に操作を再実行することはありません。手動で再試行してください。', 'sp.error_prefix': 'エラー: {msg}', 'sp.subscribe.allowance_used': 'WebBrain Cloud の無料の1日あたりの利用枠を使い切りました。', diff --git a/src/chrome/src/ui/locales/ko.js b/src/chrome/src/ui/locales/ko.js index a9b79a884..1d82f3174 100644 --- a/src/chrome/src/ui/locales/ko.js +++ b/src/chrome/src/ui/locales/ko.js @@ -153,6 +153,8 @@ export default { 'sp.copy': '복사', 'sp.copied': '복사됨!', 'sp.copy.code.title': '코드 복사', + 'sp.copy.message.title': '메시지 복사', + 'sp.persistence.unavailable': '복구 데이터를 저장할 수 없습니다. 진행 중인 작업은 계속할 수 있지만 연결이 끊긴 뒤 작업을 다시 실행하지 않습니다. 수동으로 다시 시도하세요.', 'sp.error_prefix': '오류: {msg}', 'sp.subscribe.allowance_used': 'WebBrain Cloud의 무료 일일 사용량을 모두 사용했습니다.', diff --git a/src/chrome/src/ui/locales/ms.js b/src/chrome/src/ui/locales/ms.js index f0b3de47d..1073a5712 100644 --- a/src/chrome/src/ui/locales/ms.js +++ b/src/chrome/src/ui/locales/ms.js @@ -153,6 +153,8 @@ export default { 'sp.copy': 'Salin', 'sp.copied': 'Disalin!', 'sp.copy.code.title': 'Salin kod', + 'sp.copy.message.title': 'Salin mesej', + 'sp.persistence.unavailable': 'Data pemulihan tidak dapat disimpan. Tugas langsung boleh diteruskan, tetapi tindakan tidak akan dimainkan semula selepas sambungan terputus. Cuba lagi secara manual.', 'sp.error_prefix': 'Ralat: {msg}', 'sp.subscribe.allowance_used': 'Peruntukan harian percuma WebBrain Cloud telah digunakan.', diff --git a/src/chrome/src/ui/locales/nl.js b/src/chrome/src/ui/locales/nl.js index 372b558ed..d680efd02 100644 --- a/src/chrome/src/ui/locales/nl.js +++ b/src/chrome/src/ui/locales/nl.js @@ -248,6 +248,8 @@ export default { 'sp.copy': 'Kopiëren', 'sp.copied': 'Gekopieerd!', 'sp.copy.code.title': 'Code kopiëren', + 'sp.copy.message.title': 'Bericht kopiëren', + 'sp.persistence.unavailable': 'Herstelgegevens kunnen niet worden opgeslagen. De actieve taak kan doorgaan, maar acties worden na een verbroken verbinding niet opnieuw uitgevoerd. Probeer het dan handmatig opnieuw.', 'sp.retry': 'Opnieuw proberen', 'sp.retry.busy': 'Wacht tot de huidige uitvoering is voltooid voordat u het opnieuw probeert.', 'sp.retry.attachments_unavailable': 'Bijlagen van de mislukte poging zijn niet meer beschikbaar; alleen de tekst wordt opnieuw geprobeerd.', diff --git a/src/chrome/src/ui/locales/pl.js b/src/chrome/src/ui/locales/pl.js index f38a11bd3..26656f6d3 100644 --- a/src/chrome/src/ui/locales/pl.js +++ b/src/chrome/src/ui/locales/pl.js @@ -200,6 +200,8 @@ export default { 'sp.copy': 'Kopiuj', 'sp.copied': 'Skopiowano!', 'sp.copy.code.title': 'Kopiuj kod', + 'sp.copy.message.title': 'Kopiuj wiadomość', + 'sp.persistence.unavailable': 'Nie można zapisać danych odzyskiwania. Bieżące zadanie może być kontynuowane, ale po utracie połączenia działania nie zostaną powtórzone. Spróbuj ponownie ręcznie.', 'sp.error_prefix': 'Błąd: {msg}', 'sp.subscribe.allowance_used': 'Wykorzystano dzienny darmowy limit WebBrain Cloud.', 'sp.subscribe.btn': 'Subskrybuj', diff --git a/src/chrome/src/ui/locales/pt.js b/src/chrome/src/ui/locales/pt.js index 8065d57a7..e3f94b307 100644 --- a/src/chrome/src/ui/locales/pt.js +++ b/src/chrome/src/ui/locales/pt.js @@ -255,6 +255,8 @@ export default { 'sp.copy': "Copiar", 'sp.copied': "Copiado!", 'sp.copy.code.title': "Copiar código", + 'sp.copy.message.title': "Copiar mensagem", + 'sp.persistence.unavailable': "Não foi possível salvar os dados de recuperação. A tarefa ativa pode continuar, mas as ações não serão repetidas após uma desconexão. Tente novamente de forma manual.", 'sp.retry': "Tentar novamente", 'sp.retry.busy': "Aguarde a conclusão da execução atual antes de tentar novamente.", 'sp.retry.attachments_unavailable': "Os anexos da tentativa fracassada não estão mais disponíveis; repetindo apenas o texto.", diff --git a/src/chrome/src/ui/locales/ru.js b/src/chrome/src/ui/locales/ru.js index 861da27e3..0c9837b00 100644 --- a/src/chrome/src/ui/locales/ru.js +++ b/src/chrome/src/ui/locales/ru.js @@ -153,6 +153,8 @@ export default { 'sp.copy': 'Копировать', 'sp.copied': 'Скопировано!', 'sp.copy.code.title': 'Копировать код', + 'sp.copy.message.title': 'Копировать сообщение', + 'sp.persistence.unavailable': 'Не удалось сохранить данные восстановления. Текущая задача может продолжиться, но после разрыва соединения действия не будут повторены. Повторите попытку вручную.', 'sp.error_prefix': 'Ошибка: {msg}', 'sp.subscribe.allowance_used': 'Бесплатный дневной лимит WebBrain Cloud исчерпан.', diff --git a/src/chrome/src/ui/locales/th.js b/src/chrome/src/ui/locales/th.js index 35ff1d681..8037424d9 100644 --- a/src/chrome/src/ui/locales/th.js +++ b/src/chrome/src/ui/locales/th.js @@ -153,6 +153,8 @@ export default { 'sp.copy': 'คัดลอก', 'sp.copied': 'คัดลอกแล้ว!', 'sp.copy.code.title': 'คัดลอกโค้ด', + 'sp.copy.message.title': 'คัดลอกข้อความ', + 'sp.persistence.unavailable': 'ไม่สามารถบันทึกข้อมูลการกู้คืนได้ งานที่กำลังทำยังดำเนินต่อได้ แต่จะไม่ทำซ้ำการกระทำหลังการเชื่อมต่อขาด โปรดลองใหม่ด้วยตนเอง', 'sp.error_prefix': 'ข้อผิดพลาด: {msg}', 'sp.subscribe.allowance_used': 'ใช้โควตารายวันฟรีของ WebBrain Cloud หมดแล้ว', diff --git a/src/chrome/src/ui/locales/tl.js b/src/chrome/src/ui/locales/tl.js index f58f69464..f93342f63 100644 --- a/src/chrome/src/ui/locales/tl.js +++ b/src/chrome/src/ui/locales/tl.js @@ -153,6 +153,8 @@ export default { 'sp.copy': 'Kopyahin', 'sp.copied': 'Nakopya!', 'sp.copy.code.title': 'Kopyahin ang code', + 'sp.copy.message.title': 'Kopyahin ang mensahe', + 'sp.persistence.unavailable': 'Hindi ma-save ang data sa pagbawi. Maaaring magpatuloy ang kasalukuyang gawain, ngunit hindi uulitin ang mga aksyon kapag naputol ang koneksyon. Subukang muli nang manu-mano.', 'sp.error_prefix': 'Error: {msg}', 'sp.subscribe.allowance_used': 'Naubos na ang libreng pang-araw-araw na alokasyon ng WebBrain Cloud.', diff --git a/src/chrome/src/ui/locales/tr.js b/src/chrome/src/ui/locales/tr.js index 16f189379..a15b7344a 100644 --- a/src/chrome/src/ui/locales/tr.js +++ b/src/chrome/src/ui/locales/tr.js @@ -189,6 +189,8 @@ export default { 'sp.copy': 'Kopyala', 'sp.copied': 'Kopyalandı!', 'sp.copy.code.title': 'Kodu kopyala', + 'sp.copy.message.title': 'Mesajı kopyala', + 'sp.persistence.unavailable': 'Kurtarma verileri kaydedilemiyor. Canlı görev devam edebilir ancak bağlantı koparsa işlemler yeniden oynatılmaz; elle yeniden deneyin.', 'sp.error_prefix': 'Hata: {msg}', 'sp.subscribe.allowance_used': 'Ücretsiz günlük WebBrain Cloud kullanım hakkınız doldu.', diff --git a/src/chrome/src/ui/locales/uk.js b/src/chrome/src/ui/locales/uk.js index 92bbda547..06df39beb 100644 --- a/src/chrome/src/ui/locales/uk.js +++ b/src/chrome/src/ui/locales/uk.js @@ -153,6 +153,8 @@ export default { 'sp.copy': 'Копіювати', 'sp.copied': 'Скопійовано!', 'sp.copy.code.title': 'Копіювати код', + 'sp.copy.message.title': 'Копіювати повідомлення', + 'sp.persistence.unavailable': 'Не вдалося зберегти дані відновлення. Поточне завдання може продовжитися, але після розриву з’єднання дії не повторюватимуться. Спробуйте знову вручну.', 'sp.error_prefix': 'Помилка: {msg}', 'sp.subscribe.allowance_used': 'Безкоштовний денний ліміт WebBrain Cloud вичерпано.', diff --git a/src/chrome/src/ui/locales/vi.js b/src/chrome/src/ui/locales/vi.js index 69e279914..c2becb756 100644 --- a/src/chrome/src/ui/locales/vi.js +++ b/src/chrome/src/ui/locales/vi.js @@ -255,6 +255,8 @@ export default { 'sp.copy': "Sao chép", 'sp.copied': "Đã sao chép!", 'sp.copy.code.title': "Sao chép mã", + 'sp.copy.message.title': "Sao chép tin nhắn", + 'sp.persistence.unavailable': "Không thể lưu dữ liệu khôi phục. Tác vụ đang chạy có thể tiếp tục, nhưng các hành động sẽ không được phát lại sau khi mất kết nối. Hãy thử lại theo cách thủ công.", 'sp.retry': "Thử lại", 'sp.retry.busy': "Đợi quá trình chạy hiện tại kết thúc trước khi thử lại.", 'sp.retry.attachments_unavailable': "Tệp đính kèm từ lần thử không thành công không còn tồn tại nữa; chỉ thử lại văn bản.", diff --git a/src/chrome/src/ui/locales/zh.js b/src/chrome/src/ui/locales/zh.js index c956f65c8..bff312e38 100644 --- a/src/chrome/src/ui/locales/zh.js +++ b/src/chrome/src/ui/locales/zh.js @@ -153,6 +153,8 @@ export default { 'sp.copy': '复制', 'sp.copied': '已复制!', 'sp.copy.code.title': '复制代码', + 'sp.copy.message.title': '复制消息', + 'sp.persistence.unavailable': '无法保存恢复数据。当前任务可以继续,但连接中断后不会重放任何操作;请手动重试。', 'sp.error_prefix': '错误:{msg}', 'sp.subscribe.allowance_used': '今日免费的 WebBrain Cloud 额度已用完。', diff --git a/src/chrome/src/ui/settings.js b/src/chrome/src/ui/settings.js index 7e7ea9c62..ae9139a29 100644 --- a/src/chrome/src/ui/settings.js +++ b/src/chrome/src/ui/settings.js @@ -52,7 +52,7 @@ import { ADDITIONAL_PROVIDER_UI } from '../providers/provider-catalog.js'; // Version shown in the subtitle. Kept here so it only needs one update per // release; the subtitle string itself is translated. -const EXT_VERSION = '26.0.10'; +const EXT_VERSION = '26.0.11'; const providersContainer = document.getElementById('providers'); const displaySettings = document.getElementById('display-settings'); diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js index abf61df08..cd7f17570 100644 --- a/src/chrome/src/ui/sidepanel.js +++ b/src/chrome/src/ui/sidepanel.js @@ -23,6 +23,7 @@ import { historyTextFromElement } from './history-text.js'; import { claimRunError } from './run-error-dedupe.js'; import { RUN_CAPTURE_START_ERROR_PREFIX } from '../run-capture.js'; import { runUiUnavailableBeforeSeq } from '../run-ui-journal.js'; +import { formatErrorMessage } from '../error-format.js'; import { escapeHtml } from './utils.js'; import { isBackgroundConnectionError, @@ -5643,7 +5644,7 @@ function scheduleActiveChatPayloadCleanup(tabId, state) { } function renderAgentErrorUpdate(data, tabId = currentTabId, requestId = '', options = {}) { - const message = data?.message || data?.error || 'unknown error'; + const message = formatErrorMessage(data?.message ?? data?.error ?? data); // Allowance routing depends on terminal durable-turn proof. Live error // updates arrive before that proof, so the run_complete/direct response // path owns the single actionable card. @@ -8165,6 +8166,8 @@ function handleAgentUpdateMessage(msg) { renderPlannerRequestFailure(targetAssistantEl, data, retryPayload); } else if (data?.code === 'ask_stream_fallback') { showComposerToast(t('sp.streaming.fallback'), { duration: 6000 }); + } else if (data?.code === 'persistence_degraded') { + showComposerToast(t('sp.persistence.unavailable'), { duration: 10000 }); } break; @@ -9108,9 +9111,25 @@ function clearTransientAssistantTextForToolCall() { function appendVerboseToolCall(name, args) { if (!currentAssistantEl) return; const content = currentAssistantEl.querySelector('.message-content'); + content.querySelectorAll('.tool-call[data-awaiting-result="true"]').forEach(tool => { + tool.dataset.awaitingResult = 'false'; + }); + + if (name === 'done') { + const priorRejected = content.querySelector('.tool-call[data-tool-name="done"][data-rejected-completion="true"]'); + if (priorRejected) { + priorRejected.querySelector('.tool-call-body').textContent = JSON.stringify(args, null, 2); + priorRejected.querySelector('.tool-result')?.remove(); + priorRejected.dataset.rejectedCompletion = 'pending'; + priorRejected.dataset.awaitingResult = 'true'; + return; + } + } const el = document.createElement('div'); el.className = 'tool-call'; + el.dataset.toolName = name || ''; + el.dataset.awaitingResult = 'true'; const header = document.createElement('div'); header.className = 'tool-call-header'; @@ -9133,12 +9152,16 @@ function appendVerboseToolCall(name, args) { function appendVerboseToolResult(name, result) { if (!currentAssistantEl) return; const content = currentAssistantEl.querySelector('.message-content'); - const lastTool = content.querySelector('.tool-call:last-of-type'); + const lastTool = content.querySelector('.tool-call[data-awaiting-result="true"]'); if (lastTool) { const resultEl = document.createElement('div'); resultEl.className = 'tool-result'; resultEl.textContent = truncate(JSON.stringify(result), 200); lastTool.appendChild(resultEl); + if (name === 'done') { + lastTool.dataset.rejectedCompletion = result?.blockedDone === true ? 'true' : 'false'; + } + lastTool.dataset.awaitingResult = 'false'; } } @@ -10128,12 +10151,18 @@ function addMessageCopyButton(msgEl) { if (!msgEl) return; const content = msgEl.querySelector('.message-content'); if (!content) return; + const existing = content.querySelector('.msg-copy-btn:not(.scratchpad-copy-btn)'); + if (existing) { + bindMessageCopyButton(existing); + return existing; + } const btn = document.createElement('button'); btn.className = 'msg-copy-btn'; btn.textContent = t('sp.copy'); - btn.title = t('sp.copy.code.title'); + btn.title = t('sp.copy.message.title'); bindMessageCopyButton(btn); content.appendChild(btn); + return btn; } function addScratchpadCopyButton(msgEl) { @@ -10141,12 +10170,18 @@ function addScratchpadCopyButton(msgEl) { const content = msgEl.querySelector('.message-content'); const pre = content?.querySelector('pre.scratchpad-dump'); if (!content || !pre) return; + const existing = content.querySelector('.scratchpad-copy-btn'); + if (existing) { + bindMessageCopyButton(existing); + return existing; + } const btn = document.createElement('button'); btn.className = 'msg-copy-btn scratchpad-copy-btn'; btn.textContent = t('sp.copy'); btn.title = t('sp.copy.code.title'); bindMessageCopyButton(btn); content.appendChild(btn); + return btn; } function getMessageCopyText(btn) { diff --git a/src/chrome/src/ui/tab-chat-persistence.js b/src/chrome/src/ui/tab-chat-persistence.js index 236eecf48..c73702b6f 100644 --- a/src/chrome/src/ui/tab-chat-persistence.js +++ b/src/chrome/src/ui/tab-chat-persistence.js @@ -141,45 +141,6 @@ export async function persistTabChatToSession(storageArea, key, html, warn = con retryError = error; } - try { - // Older per-tab chats can consume nearly the entire shared quota. Free - // the largest stored chats one at a time and retry after each removal. - // Removal is intentionally used instead of rewriting a stale get(null) - // snapshot: a concurrent clear remains cleared rather than being - // resurrected by quota recovery in another panel context. - const stored = await storageArea.get(null); - const candidates = Object.entries(stored || {}) - .filter(([storedKey, value]) => ( - storedKey !== key - && storedKey.startsWith(TAB_CHAT_PREFIX) - && typeof value === 'string' - )) - .sort((a, b) => b[1].length - a[1].length); - const evictedKeys = []; - - for (const [storedKey] of candidates) { - try { - await storageArea.remove(storedKey); - evictedKeys.push(storedKey); - } catch { - continue; - } - try { - await storageArea.set({ [key]: retryValue }); - return { - ok: true, - degraded: true, - recoveredFromQuota: true, - evictedKeys, - }; - } catch (error) { - retryError = error; - } - } - } catch (error) { - retryError = error; - } - try { warn( '[WebBrain] persistTabChat: session storage write failed after compacting the stored copy; chat may not survive a panel reopen:', diff --git a/src/firefox/ARCHITECTURE.md b/src/firefox/ARCHITECTURE.md index 197aff236..aad7ef7a1 100644 --- a/src/firefox/ARCHITECTURE.md +++ b/src/firefox/ARCHITECTURE.md @@ -1,6 +1,6 @@ # WebBrain Firefox Extension — Architecture -> Version 26.0.10 · Manifest V2 · Background Page +> Version 26.0.11 · Manifest V2 · Background Page ## How Firefox Differs from Chrome diff --git a/src/firefox/manifest.json b/src/firefox/manifest.json index 6881cc6e7..1d0766a85 100644 --- a/src/firefox/manifest.json +++ b/src/firefox/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 2, "name": "WebBrain", - "version": "26.0.10", + "version": "26.0.11", "description": "Open-source AI browser agent — chat with pages, automate tasks, multi-provider LLM support.", "permissions": [ "activeTab", diff --git a/src/firefox/src/agent/agent.js b/src/firefox/src/agent/agent.js index 08f95c55d..8ed22c66e 100644 --- a/src/firefox/src/agent/agent.js +++ b/src/firefox/src/agent/agent.js @@ -1,4 +1,7 @@ import { AGENT_TOOLS, AGENT_TOOL_NAMES, RESERVED_AGENT_TOOL_NAMES, getToolsForMode, SYSTEM_PROMPT_ASK, SYSTEM_PROMPT_ACT, SYSTEM_PROMPT_ACT_COMPACT, SYSTEM_PROMPT_ACT_MID, SYSTEM_PROMPT_DEV_APPENDIX } from './tools.js'; +import { validateToolArguments } from './tool-arguments.js'; +import { isSessionQuotaError, serializeConversationForSession, SESSION_CONVERSATION_BUDGET_BYTES, SESSION_CONVERSATION_RETRY_BUDGET_BYTES } from './conversation-persistence.js'; +import { formatErrorMessage } from '../error-format.js'; import { handleDoneJson } from './cloud-output.js'; import { applyReadPageWindow, fitReadPageWindowResult, isReadPageWindowResult } from './read-page-window.js'; import { LoopDetector } from './loop-detector.js'; @@ -277,6 +280,9 @@ export class Agent extends LoopDetector { this.conversationModes = new Map(); // tabId -> 'ask' | 'act' | 'dev' this._runModeOverrides = new Map(); // tabId -> effective mode for the active run only this.submittedRunRequestIds = new Map(); // tabId -> request whose user turn is durable in storage.session + this.persistenceDegradedTabs = new Map(); // tabId -> non-durable recovery state after storage failure + this._persistenceWarningKeys = new Set(); + this._runUpdateCallbacks = new Map(); this.plannerFollowUpSkipTabs = new Set(); // tabIds allowed one short follow-up after an approved try-mode plan this.hydratedTabs = new Set(); // tabIds we've already pulled from storage this.persistTimers = new Map(); // tabId -> debounce handle @@ -774,10 +780,13 @@ export class Agent extends LoopDetector { } activeRunState(tabId) { + const persistenceState = this.persistenceDegradedTabs.get(tabId) || null; const state = { running: this._runningTabs.has(tabId), runId: this.currentRunId.get(tabId) || null, pendingPlan: null, + persistenceDegraded: !!persistenceState, + persistenceDegradedReason: persistenceState?.reason || null, }; const tabPending = this._pendingPlans.get(tabId); if (tabPending?.size) { @@ -872,7 +881,7 @@ export class Agent extends LoopDetector { } catch (e) { /* session storage may be unavailable */ } } - _conversationStorageEntry(tabId) { + _conversationStorageEntry(tabId, options = {}) { const messages = this.conversations.get(tabId); if (!messages) return null; const conversationId = this.conversationIds.get(tabId) || null; @@ -888,14 +897,14 @@ export class Agent extends LoopDetector { updatedAt: Number(clarificationGuard.updatedAt) || Date.now(), } : null; - const persistedMessages = messages.map(message => ( - message?.transientCompletionVerification === true - ? { role: 'user', content: '[Completion verification screenshot omitted from persisted history.]' } - : message - )); + const serialized = serializeConversationForSession(messages, { + maxBytes: options.maxBytes || SESSION_CONVERSATION_BUDGET_BYTES, + }); return { mode: this.conversationModes.get(tabId) || 'ask', - messages: persistedMessages, + messages: serialized.messages, + sessionSnapshotCompacted: serialized.compacted, + sessionSnapshotBytes: serialized.bytes, conversationId, submittedRunRequestId: this.submittedRunRequestIds.get(tabId) || null, progressLedger: this.progressLedgers.get(tabId) || [], @@ -906,17 +915,58 @@ export class Agent extends LoopDetector { }; } + _markPersistenceDegraded(tabId, reason, error = null) { + const state = { + reason: reason || 'unavailable', + requestId: this.submittedRunRequestIds.get(tabId) || null, + runId: this.currentRunId.get(tabId) || null, + at: Date.now(), + }; + this.persistenceDegradedTabs.set(tabId, state); + this.submittedRunRequestIds.delete(tabId); + const warningKey = state.runId || state.requestId || `tab:${tabId}`; + if (!this._persistenceWarningKeys.has(warningKey)) { + this._persistenceWarningKeys.add(warningKey); + this._runUpdateCallbacks.get(tabId)?.('warning', { + code: 'persistence_degraded', + persistenceDegraded: true, + reason: state.reason, + message: 'Recovery persistence is unavailable. The live task can continue, but WebBrain will not replay actions after a connection loss; retry manually if disconnected.', + }); + } + return { + ok: false, + degraded: true, + reason: state.reason, + errorCode: 'persistence_unavailable', + error: error?.message || null, + }; + } + async _persistNow(tabId) { - if (tabId == null) return false; + if (tabId == null) return { ok: false, degraded: false, reason: 'invalid_tab' }; const existing = this.persistTimers.get(tabId); if (existing) { clearTimeout(existing); this.persistTimers.delete(tabId); } const entry = this._conversationStorageEntry(tabId); - if (!entry) return false; - await browser.storage.session.set({ [this._convKey(tabId)]: entry }); - return true; + if (!entry) return { ok: false, degraded: false, reason: 'no_conversation' }; + try { + await browser.storage.session.set({ [this._convKey(tabId)]: entry }); + return { ok: true, degraded: entry.sessionSnapshotCompacted === true, reason: entry.sessionSnapshotCompacted ? 'sanitized' : null }; + } catch (error) { + if (isSessionQuotaError(error)) { + const compactEntry = this._conversationStorageEntry(tabId, { maxBytes: SESSION_CONVERSATION_RETRY_BUDGET_BYTES }); + try { + await browser.storage.session.set({ [this._convKey(tabId)]: compactEntry }); + return { ok: true, degraded: true, reason: 'quota_compacted' }; + } catch (retryError) { + return this._markPersistenceDegraded(tabId, isSessionQuotaError(retryError) ? 'quota' : 'unavailable', retryError); + } + } + return this._markPersistenceDegraded(tabId, 'unavailable', error); + } } /** @@ -929,7 +979,7 @@ export class Agent extends LoopDetector { if (existing) clearTimeout(existing); const handle = setTimeout(() => { this.persistTimers.delete(tabId); - this._persistNow(tabId).catch(() => {}); + void this._persistNow(tabId); }, 300); this.persistTimers.set(tabId, handle); } @@ -942,10 +992,9 @@ export class Agent extends LoopDetector { } const previousRequestId = this.submittedRunRequestIds.get(tabId); this.submittedRunRequestIds.set(tabId, cleanRequestId); - try { - await this._persistNow(tabId); - return true; - } catch { + const persisted = await this._persistNow(tabId); + if (persisted.ok) return true; + { if (previousRequestId) this.submittedRunRequestIds.set(tabId, previousRequestId); else this.submittedRunRequestIds.delete(tabId); return false; @@ -956,6 +1005,7 @@ export class Agent extends LoopDetector { const cleanRequestId = String(requestId || ''); if (!cleanRequestId) return false; await this._hydrate(tabId); + if (this.persistenceDegradedTabs.has(tabId)) return false; return this.submittedRunRequestIds.get(tabId) === cleanRequestId; } @@ -984,6 +1034,8 @@ export class Agent extends LoopDetector { return { conversationId: this.conversationIds.get(tabId) || null, sourceGrounding: selectionGrounded ? SELECTION_ONLY_SOURCE_GROUNDING : null, + persistenceDegraded: this.persistenceDegradedTabs.has(tabId), + persistenceDegradedReason: this.persistenceDegradedTabs.get(tabId)?.reason || null, }; } @@ -1454,7 +1506,7 @@ export class Agent extends LoopDetector { } _interactiveAskStreamingFailure(error) { - const rawMessage = String(error?.message || error || 'Streaming request failed.'); + const rawMessage = formatErrorMessage(error, { fallback: 'Streaming request failed.' }); const message = rawMessage .replace(/\b(Bearer)\s+[^\s,;]+/gi, '$1 [redacted]') .replace(/((?:^|[^a-zA-Z0-9_])["']?(?:api[_ -]?key|access[_ -]?token|token|secret|password)["']?\s*[:=]\s*)(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,;}\]]+)/gi, '$1[redacted]') @@ -2064,13 +2116,39 @@ export class Agent extends LoopDetector { _invalidToolArgumentsResult(fnName, parsed) { return { success: false, + invalidArguments: true, invalidToolArguments: true, + noDispatch: true, + dispatched: false, + errorCode: 'invalid_tool_arguments', error: `${fnName || 'tool'} could not run because its arguments were not valid JSON. Re-emit the same tool call with a valid JSON object for arguments; do not assume the action happened.`, detail: parsed?.error || 'invalid JSON', rawPreview: parsed?.rawPreview || '', }; } + _toolParametersForValidation(tabId, fnName, toolSchemas = null) { + const advertised = toolSchemas instanceof Map ? toolSchemas.get(fnName) : null; + if (advertised) return advertised; + const builtIn = AGENT_TOOLS.find(tool => tool.function?.name === fnName)?.function?.parameters; + if (fnName === 'done') { + return { + type: 'object', + properties: { + summary: { type: 'string' }, + outcome: { type: 'string', enum: ['success', 'partial', 'failed'] }, + result: { type: 'object' }, + }, + required: ['summary'], + }; + } + if (builtIn) return builtIn; + if (fnName === 'load_skill') { + return this._skillLoaderDefinition(this._effectiveRunMode(tabId), this._resolvePromptTier())?.function?.parameters || null; + } + return this._activeSkillToolForName(tabId, fnName)?.parameters || null; + } + _normalizeToolResult(fnName, result, outcomeUnknown = Agent.STATE_CHANGE_TOOLS.has(fnName)) { if (result != null) return result; return { @@ -3290,7 +3368,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d * caller for one terminal, tool-free salvage response after loop stopping; * `deliver` asks for one terminal turn with only the `done` tool available. */ - async _executeToolBatch(tabId, toolCalls, messages, onUpdate, provider, partialAssistantText = null, allowedToolNames = AGENT_TOOL_NAMES, step = null, runOptions = {}) { + async _executeToolBatch(tabId, toolCalls, messages, onUpdate, provider, partialAssistantText = null, allowedToolNames = AGENT_TOOL_NAMES, step = null, runOptions = {}, toolSchemas = null) { let didStateChange = false; const promptTier = this._resolvePromptTier(); const completionBatchStartState = this.completionInvariants.get(tabId) || null; @@ -3363,6 +3441,18 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d const argRepair = this._repairToolCallArgs(fnName, parsedArgs.args); const fnArgs = this._toolCallArgsWithReplayMethod(tabId, fnName, argRepair.args); const argRepairNotice = argRepair.note || ''; + const parameters = this._toolParametersForValidation(tabId, fnName, toolSchemas); + const argumentValidation = parameters ? validateToolArguments(fnName, fnArgs, parameters) : { ok: true }; + if (!argumentValidation.ok) { + const result = argumentValidation.result; + onUpdate('tool_call', { name: fnName, args: fnArgs, outcomeUnknown: false }); + onUpdate('tool_result', { name: fnName, result }); + messages.push({ role: 'tool', tool_call_id: tc.id, content: JSON.stringify(result) }); + const runId = this.currentRunId.get(tabId); + if (runId) trace.recordToolCall(runId, step, { name: fnName, args: fnArgs, result, latencyMs: 0 }); + if (interruptFailedBrowserAction(toolIndex, fnName)) { navNotices.length = 0; break; } + continue; + } // A verification challenge is a runtime state boundary, not a prompt // suggestion. Once observed, no model-authored click/close/submit or @@ -4258,8 +4348,8 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d content: resultContent, }); if (missingResponseOutcomeUnknown && typeof runOptions?.afterConsequentialTool === 'function') { - const conversationDurable = await this._persistNow(tabId).catch(() => false); - if (conversationDurable) { + const conversationDurable = await this._persistNow(tabId); + if (conversationDurable === true || conversationDurable?.ok === true) { try { await runOptions.afterConsequentialTool({ name: fnName }); } catch {} @@ -6712,7 +6802,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d _plannerRequestFailure(error, onUpdate, provider = null) { const detail = sanitizePlannerText( - error?.message || String(error || 'Unknown planner request error.'), + formatErrorMessage(error, { fallback: 'Unknown planner request error.' }), 500, { collapseWhitespace: true }, ); @@ -7257,7 +7347,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d { step, runOptions, currentUserMessage, priorMessageSet }, ); } catch (error) { - this._logDebug({ type: 'delivery_recovery_error', step, error: error?.message || String(error) }); + this._logDebug({ type: 'delivery_recovery_error', step, error: formatErrorMessage(error) }); } const stopped = this._consumeContextOnlyAbort(tabId, messages, onUpdate); if (stopped) return stopped; @@ -7447,7 +7537,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d status = this._isCostAllowanceError(error) ? 'cost_limit' : 'error'; finalResponse = this._isCostAllowanceError(error) ? error.message - : `I could not generate the requested response: ${error?.message || String(error)}`; + : `I could not generate the requested response: ${formatErrorMessage(error)}`; } const stopped = this._consumeContextOnlyAbort(tabId, messages, onUpdate); if (stopped) return stopped; @@ -7488,7 +7578,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d { phase: 'terminal_recovery', step, runOptions, currentUserMessage, priorMessageSet }, ); } catch (error) { - this._logDebug({ type: 'terminal_recovery_error', step, error: error?.message || String(error) }); + this._logDebug({ type: 'terminal_recovery_error', step, error: formatErrorMessage(error) }); } } const stopped = this._consumeContextOnlyAbort(tabId, messages, onUpdate); @@ -9112,6 +9202,8 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d this.conversationModes.delete(tabId); this.conversationIds.delete(tabId); this.submittedRunRequestIds.delete(tabId); + this.persistenceDegradedTabs.delete(tabId); + this._runUpdateCallbacks.delete(tabId); this._lastInputTokens.delete(tabId); this._lastEstCharsAtReport.delete(tabId); this._compactCooldown.delete(tabId); @@ -9150,6 +9242,8 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d this.completionInvariants.delete(tabId); this._captchaGateStates.delete(tabId); this._userAttachmentHandles.delete(tabId); + this._runUpdateCallbacks.delete(tabId); + if (!preserveRunGuard) this.persistenceDegradedTabs.delete(tabId); if (!preserveRunGuard) { this._runningTabs.delete(tabId); this.currentRunId.delete(tabId); @@ -10864,6 +10958,16 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d && /\b(?:refus|will not|do not proceed|unauthorized|illegal|fraud|theft|unsafe|cannot assist|can't assist)\b/i.test(text); } + _isPlannerShapedJson(content) { + const object = extractFirstJsonObject(String(content || '')); + if (!object || typeof object !== 'object' || Array.isArray(object)) return false; + const plannerKeys = ['request_kind', 'requires_state_change', 'requires_submission', 'allows_planner_shaped_result', 'confidence', 'memory', 'scheduling', 'risks', 'localized']; + const hasPlannerMetadata = plannerKeys.some(key => Object.prototype.hasOwnProperty.call(object, key)); + return hasPlannerMetadata + && (typeof object.summary === 'string' || typeof object.localized?.summary === 'string') + && (Array.isArray(object.steps) || Array.isArray(object.localized?.steps)); + } + _looksLikePlanOnlyTerminal(content, state = {}, { ignoreFuturePromise = false } = {}) { const text = String(content || '').trim(); if (!text) return false; @@ -10885,6 +10989,8 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d && String(object.mode || '').toLowerCase() !== 'inactive'; if (plannerShape || policyShape) return state.allowsPlannerShapedResult !== true; } + const runtimeModeContradiction = /\b(?:switch|change|set)\s+(?:back\s+)?to\s+act\s+mode\b|\b(?:currently|still|now)\s+(?:running\s+)?in\s+ask\s+mode\b/i.test(text); + if (runtimeModeContradiction) return true; // "Next, I will …" / "I plan to …" is agent-continue language and is always // invalid as a terminal. Bare "I will …" is evidence-gated so drafted reply // text can finish after a real task tool without a planner exemption flag. @@ -10956,8 +11062,8 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d } 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.]' - : '[Agent stopped because the model returned another plain terminal or a plan/promise instead of completing the execute protocol, even after one recovery nudge. No successful action was verified.]', + ? 'Some task tools completed, but I could not verify a valid completion after the recovery attempt. Please inspect the current page before retrying to avoid duplicate side effects.' + : 'I could not verify any requested page action after the recovery attempt, so I stopped without claiming completion. No successful action was verified, and nothing was verified as submitted or sent.', status: 'plan_only_output', }; } @@ -15248,6 +15354,8 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d if (runOptions?.trustedContinuation !== true) this._continuationExecutionEvidence.delete(tabId); const completionRunToken = this._beginCompletionInvariant(tabId); this._runningTabs.add(tabId); + if (runOptions?.trustedContinuation !== true) this.persistenceDegradedTabs.delete(tabId); + this._runUpdateCallbacks.set(tabId, onUpdate); this._runModeOverrides.set(tabId, mode); const previousCloudContext = this.cloudRunContexts.get(tabId); if (runOptions.cloudRun) { @@ -15267,6 +15375,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d else this.cloudRunContexts.delete(tabId); } this._userAttachmentHandles.delete(tabId); + this._runUpdateCallbacks.delete(tabId); this._runningTabs.delete(tabId); this._clearRunLoopState(tabId); this._clearCompletionInvariant(tabId, completionRunToken); @@ -15286,6 +15395,13 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d */ async _applyAttachments(enriched, attachments, provider, options = {}) { attachments = this._registerUserAttachments(options.tabId, attachments); + enriched.attachmentHandles = attachments.map(att => ({ + attachmentId: att.attachmentId, + kind: att.kind, + name: att.name || null, + mimeType: att.mimeType || null, + size: Number(att.size) || null, + })); const blocks = []; const textAttachmentCount = (attachments || []).filter(att => att?.kind === 'text').length; let textBudgetRemaining = this._textAttachmentContentBudget(provider, { ...options, enriched }); @@ -15553,6 +15669,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d // second source and defeat the selection-only boundary. if (selectionOnly) tools = []; let allowedToolNames = new Set(tools.map(t => t.function.name)); + let toolSchemas = new Map(tools.map(t => [t.function.name, t.function.parameters])); const plannerTemperature = this._isActionMode(mode) ? 0.15 : 0.3; let steps = 0; // Tracks whether we've already nudged the model after an empty @@ -15714,6 +15831,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d }); if (selectionOnly) tools = []; allowedToolNames = new Set(tools.map(t => t.function.name)); + toolSchemas = new Map(tools.map(t => [t.function.name, t.function.parameters])); // Auto-compact mid-run when the conversation outgrows the budget — not // just between user turns. Uses the previous step's reported token count, @@ -15876,14 +15994,19 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d } if (result.toolCalls && result.toolCalls.length > 0) { + const suppressPlannerContent = this._isPlannerShapedJson(result.content); + const assistantToolContent = suppressPlannerContent ? null : (result.content || null); + if (suppressPlannerContent) { + this._logDebug({ type: 'planner_shaped_content_suppressed', step: steps, toolCallCount: result.toolCalls.length }); + } messages.push(this._withResponseItems({ role: 'assistant', - content: result.content || null, + content: assistantToolContent, tool_calls: result.toolCalls, }, result.responseItems, result.reasoningContent, provider)); const batchResult = await this._executeToolBatch( - tabId, result.toolCalls, messages, onUpdate, provider, result.content, allowedToolNames, steps, runOptions + tabId, result.toolCalls, messages, onUpdate, provider, assistantToolContent, allowedToolNames, steps, runOptions, toolSchemas ); if (batchResult.action === 'return') { finalResponse = batchResult.value; @@ -16063,7 +16186,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d this._persist(tabId); return finalResponse; } catch (error) { - const message = error?.message || String(error); + const message = formatErrorMessage(error); _traceStatus = 'error'; finalResponse = `Error: ${message}`; if (runId) { @@ -16090,6 +16213,8 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d if (runOptions?.trustedContinuation !== true) this._continuationExecutionEvidence.delete(tabId); const completionRunToken = this._beginCompletionInvariant(tabId); this._runningTabs.add(tabId); + if (runOptions?.trustedContinuation !== true) this.persistenceDegradedTabs.delete(tabId); + this._runUpdateCallbacks.set(tabId, onUpdate); this._runModeOverrides.set(tabId, mode); const previousCloudContext = this.cloudRunContexts.get(tabId); if (runOptions.cloudRun) { @@ -16109,6 +16234,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d else this.cloudRunContexts.delete(tabId); } this._userAttachmentHandles.delete(tabId); + this._runUpdateCallbacks.delete(tabId); this._runningTabs.delete(tabId); this._clearRunLoopState(tabId); this._clearCompletionInvariant(tabId, completionRunToken); @@ -16265,6 +16391,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d // page or network content cannot be introduced after the source anchor. if (selectionOnly) tools = []; let allowedToolNames = new Set(tools.map(t => t.function.name)); + let toolSchemas = new Map(tools.map(t => [t.function.name, t.function.parameters])); const plannerTemperature = this._isActionMode(mode) ? 0.15 : 0.3; let steps = 0; // See processMessage — used to break the empty-response→nudge cycle. @@ -16306,6 +16433,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d }); if (selectionOnly) tools = []; allowedToolNames = new Set(tools.map(t => t.function.name)); + toolSchemas = new Map(tools.map(t => [t.function.name, t.function.parameters])); // Auto-compact mid-run when the conversation outgrows the budget. The // streaming path doesn't get a per-call token count, so this leans on @@ -16403,6 +16531,12 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d return finish(costStopMessage, 'cost_limit'); } const toolCalls = Object.values(toolCallsAccumulator); + const suppressPlannerContent = this._isPlannerShapedJson(fullText); + if (suppressPlannerContent) { + this._logDebug({ type: 'planner_shaped_content_suppressed', step: steps, toolCallCount: toolCalls.length }); + fullText = ''; + onUpdate('text', { content: '', replace: true }); + } this._logDebug({ type: 'llm_stream_response', step: steps, content: fullText, toolCalls }); messages.push(this._withResponseItems({ role: 'assistant', @@ -16410,7 +16544,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d tool_calls: toolCalls, }, responseItems, reasoningContent, provider)); const batchResult = await this._executeToolBatch( - tabId, toolCalls, messages, onUpdate, provider, fullText, allowedToolNames, steps, runOptions + tabId, toolCalls, messages, onUpdate, provider, fullText, allowedToolNames, steps, runOptions, toolSchemas ); if (batchResult.action === 'return') { if (batchResult.status) { @@ -16575,7 +16709,8 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d return finish(fullText); } catch (e) { - this._logDebug({ type: 'llm_stream_error', step: steps, error: e.message }); + const caughtMessage = formatErrorMessage(e); + this._logDebug({ type: 'llm_stream_error', step: steps, error: caughtMessage }); // If context overflow, trim and retry if (this._isContextOverflow(e.message)) { onUpdate('thinking', { step: steps, note: 'Context too large, trimming...' }); @@ -16583,8 +16718,8 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d this._persist(tabId); continue; // retry the loop with trimmed context } - onUpdate('error', { message: e.message }); - const errMsg = `Error: ${e.message}`; + onUpdate('error', { message: caughtMessage }); + const errMsg = `Error: ${caughtMessage}`; messages.push({ role: 'assistant', content: errMsg }); this._persist(tabId); return finish(errMsg, 'error'); @@ -16600,7 +16735,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d this._persist(tabId); return finish(summary, 'max_steps'); } catch (error) { - const message = error?.message || String(error); + const message = formatErrorMessage(error); _traceStatus = 'error'; finalResponse = `Error: ${message}`; if (runId) trace.recordError(runId, null, 'agent', message); diff --git a/src/firefox/src/agent/conversation-persistence.js b/src/firefox/src/agent/conversation-persistence.js new file mode 100644 index 000000000..ee60ad90b --- /dev/null +++ b/src/firefox/src/agent/conversation-persistence.js @@ -0,0 +1,123 @@ +export const SESSION_CONVERSATION_BUDGET_BYTES = 1_500_000; +export const SESSION_CONVERSATION_RETRY_BUDGET_BYTES = 450_000; + +const DATA_URL_RE = /data:(?:image|application)\/[a-zA-Z0-9+.-]+(?:;[^,\s]*)?;base64,[A-Za-z0-9+/=\s]+/g; + +function byteLength(value) { + return new TextEncoder().encode(JSON.stringify(value)).byteLength; +} + +function capText(value, maxChars, marker, state) { + const sanitized = String(value || '').replace(DATA_URL_RE, () => { + state.compacted = true; + return '[embedded binary data omitted from session recovery]'; + }); + if (sanitized.length <= maxChars) return sanitized; + state.compacted = true; + return `${sanitized.slice(0, Math.max(0, maxChars - marker.length - 1))}\n${marker}`; +} + +function attachmentPlaceholder(message, kind) { + const handles = Array.isArray(message?.attachmentHandles) ? message.attachmentHandles : []; + if (handles.length) { + const ids = handles.map(handle => String(handle?.attachmentId || '')).filter(Boolean).slice(0, 8); + return `[User ${kind} attachment bytes omitted from session recovery; durable attachment handle(s): ${ids.join(', ') || 'available in chat history'}.]`; + } + return `[${kind === 'image' ? 'Screenshot/image' : 'Document'} bytes omitted from session recovery.]`; +} + +function sanitizeValue(value, state, depth = 0) { + if (typeof value === 'string') return capText(value, 32_000, '[large value truncated for session recovery]', state); + if (!value || typeof value !== 'object' || depth > 6) return value; + if (Array.isArray(value)) return value.slice(0, 100).map(item => sanitizeValue(item, state, depth + 1)); + const out = {}; + for (const [key, child] of Object.entries(value).slice(0, 100)) { + if (typeof child === 'string' && (/^(?:data|url)$/i.test(key)) && /^data:.*;base64,/i.test(child)) { + state.compacted = true; + out[key] = '[embedded binary data omitted from session recovery]'; + } else { + out[key] = sanitizeValue(child, state, depth + 1); + } + } + return out; +} + +function sanitizeContent(message, state, caps) { + if (message?.transientCompletionVerification === true) { + state.compacted = true; + return '[Completion verification screenshot omitted from persisted history.]'; + } + const content = message?.content; + if (typeof content === 'string') { + const cap = message.role === 'tool' ? caps.toolChars : caps.textChars; + return capText(content, cap, '[large content truncated for session recovery]', state); + } + if (!Array.isArray(content)) return sanitizeValue(content, state); + return content.slice(0, 100).map(block => { + if (block?.type === 'image_url' || block?.type === 'image') { + state.compacted = true; + return { type: 'text', text: attachmentPlaceholder(message, 'image') }; + } + if (block?.type === 'document' || block?.source?.type === 'base64') { + state.compacted = true; + return { type: 'text', text: attachmentPlaceholder(message, 'document') }; + } + return sanitizeValue(block, state); + }); +} + +function sanitizeMessage(message, state, caps) { + if (!message || typeof message !== 'object') return message; + const out = { ...message, content: sanitizeContent(message, state, caps) }; + if (Array.isArray(message.tool_calls)) { + out.tool_calls = message.tool_calls.slice(0, 50).map(call => ({ + ...call, + function: call?.function ? { + ...call.function, + arguments: capText(call.function.arguments || '', caps.toolArgsChars, '[tool arguments truncated for session recovery]', state), + } : call?.function, + })); + } + if (Array.isArray(message.responseItems)) out.responseItems = sanitizeValue(message.responseItems, state); + return out; +} + +function reduceToBudget(messages, maxBytes, state) { + if (byteLength(messages) <= maxBytes) return messages; + const out = messages.map(message => ({ ...message })); + const keepRecentFrom = Math.max(1, out.length - 14); + for (let index = 1; index < keepRecentFrom && byteLength(out) > maxBytes; index++) { + const message = out[index]; + if (!message || message.role === 'system') continue; + state.compacted = true; + out[index] = { + role: message.role, + ...(message.tool_call_id ? { tool_call_id: message.tool_call_id } : {}), + content: '[Earlier message omitted from bounded session recovery snapshot.]', + }; + } + for (let index = keepRecentFrom; index < out.length && byteLength(out) > maxBytes; index++) { + const message = out[index]; + if (!message || typeof message.content !== 'string' || message.content.length <= 4_000) continue; + state.compacted = true; + out[index] = { ...message, content: `${message.content.slice(0, 3_900)}\n[content truncated for session recovery]` }; + } + return out; +} + +export function serializeConversationForSession(messages, options = {}) { + const maxBytes = Number.isFinite(options.maxBytes) ? Math.max(100_000, options.maxBytes) : SESSION_CONVERSATION_BUDGET_BYTES; + const tight = maxBytes <= SESSION_CONVERSATION_RETRY_BUDGET_BYTES; + const caps = tight + ? { textChars: 16_000, toolChars: 8_000, toolArgsChars: 8_000 } + : { textChars: 96_000, toolChars: 32_000, toolArgsChars: 24_000 }; + const state = { compacted: false }; + const sanitized = Array.isArray(messages) ? messages.map(message => sanitizeMessage(message, state, caps)) : []; + const bounded = reduceToBudget(sanitized, maxBytes, state); + return { messages: bounded, bytes: byteLength(bounded), compacted: state.compacted }; +} + +export function isSessionQuotaError(error) { + const message = String(error?.message || error || ''); + return /quota|QUOTA_BYTES|bytes? exceeded|storage limit/i.test(message); +} diff --git a/src/firefox/src/agent/planner.js b/src/firefox/src/agent/planner.js index fd58f0e8b..3eb1be39d 100644 --- a/src/firefox/src/agent/planner.js +++ b/src/firefox/src/agent/planner.js @@ -114,10 +114,13 @@ Rules: - Classify the user's semantic intent across any language; never rely on literal keywords or UI labels. - execute means the user authorizes action. A request to plan and then perform is execute. - 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. +- Runtime mode does not force execute. In Act mode, an advice, explanation, or drafting follow-up is still respond when trusted conversation context already contains everything needed. +- Require execute only when the answer genuinely needs fresh page, browser, or network evidence. Do not reread a page merely because Act mode is selected. - 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. +- When a required form value is unavailable from trusted or public evidence, leave the field untouched and classify as clarify. Never plan to focus, clear, or write an empty value as a stand-in for missing personal information. - 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/agent/tool-arguments.js b/src/firefox/src/agent/tool-arguments.js new file mode 100644 index 000000000..2f10d75e5 --- /dev/null +++ b/src/firefox/src/agent/tool-arguments.js @@ -0,0 +1,126 @@ +function isPlainObject(value) { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function valueMatchesType(value, type) { + if (type === 'object') return isPlainObject(value); + if (type === 'array') return Array.isArray(value); + if (type === 'integer') return Number.isInteger(value); + if (type === 'number') return typeof value === 'number' && Number.isFinite(value); + if (type === 'null') return value === null; + return typeof value === type; +} + +function validationFailure(toolName, invalidArguments, detail) { + const fields = [...new Set(invalidArguments.map(String))]; + return { + ok: false, + result: { + success: false, + invalidArguments: true, + invalidToolArguments: true, + noDispatch: true, + dispatched: false, + errorCode: 'invalid_tool_arguments', + invalidArgumentNames: fields, + error: `${toolName || 'Tool'} could not run because its arguments do not match the advertised schema. Re-emit the call with only declared, valid arguments; do not assume the action happened.`, + detail, + }, + }; +} + +function validateValue(value, schema, path, failures) { + if (!schema || typeof schema !== 'object') return; + const acceptedTypes = Array.isArray(schema.type) ? schema.type : (schema.type ? [schema.type] : []); + if (acceptedTypes.length && !acceptedTypes.some(type => valueMatchesType(value, type))) { + failures.push(path); + return; + } + if (Array.isArray(schema.enum) && !schema.enum.some(candidate => Object.is(candidate, value))) { + failures.push(path); + return; + } + if (typeof value === 'string') { + if (Number.isFinite(schema.minLength) && value.length < schema.minLength) failures.push(path); + if (Number.isFinite(schema.maxLength) && value.length > schema.maxLength) failures.push(path); + } + if (Array.isArray(value) && schema.items) { + value.forEach((item, index) => validateValue(item, schema.items, `${path}[${index}]`, failures)); + } + if (!isPlainObject(value)) return; + const properties = isPlainObject(schema.properties) ? schema.properties : {}; + for (const required of Array.isArray(schema.required) ? schema.required : []) { + if (!Object.prototype.hasOwnProperty.call(value, required)) failures.push(`${path}.${required}`); + } + if (schema.additionalProperties !== true && typeof schema.additionalProperties !== 'object') { + for (const key of Object.keys(value)) { + if (!Object.prototype.hasOwnProperty.call(properties, key)) failures.push(`${path}.${key}`); + } + } + for (const [key, child] of Object.entries(value)) { + if (Object.prototype.hasOwnProperty.call(properties, key)) { + validateValue(child, properties[key], `${path}.${key}`, failures); + } + } +} + +function validateClickTarget(args) { + const text = typeof args.text === 'string' && args.text.trim() !== ''; + const selector = typeof args.selector === 'string' && args.selector.trim() !== ''; + const index = Number.isInteger(args.index) && args.index >= 0; + const hasX = typeof args.x === 'number' && Number.isFinite(args.x); + const hasY = typeof args.y === 'number' && Number.isFinite(args.y); + const coordinates = hasX && hasY && !(args.x === 0 && args.y === 0); + const strategies = [text, selector, index, coordinates].filter(Boolean).length; + const invalidCoordinates = hasX !== hasY || ((hasX && hasY) && args.x === 0 && args.y === 0); + if (strategies !== 1 || invalidCoordinates || (args.from_screenshot === true && !coordinates)) { + return validationFailure('click', ['target'], 'Provide exactly one target strategy: non-empty text, non-empty selector, a non-negative integer index, or a complete non-zero x/y coordinate pair.'); + } + return null; +} + +export function closeToolDefinition(tool) { + if (!tool?.function) return tool; + const parameters = tool.function.parameters; + if (!isPlainObject(parameters)) return tool; + const closeSchema = (schema) => { + if (!isPlainObject(schema)) return schema; + const closed = { ...schema }; + if (isPlainObject(schema.properties)) { + closed.properties = Object.fromEntries(Object.entries(schema.properties).map(([key, child]) => [key, closeSchema(child)])); + } + if (schema.items) closed.items = closeSchema(schema.items); + if (schema.type === 'object' && schema.additionalProperties === undefined) closed.additionalProperties = false; + return closed; + }; + return { + ...tool, + function: { + ...tool.function, + parameters: closeSchema(parameters), + }, + }; +} + +export function closeToolDefinitions(tools) { + return Array.isArray(tools) ? tools.map(closeToolDefinition) : []; +} + +export function validateToolArguments(toolName, args, parameters) { + if (!isPlainObject(args)) { + return validationFailure(toolName, ['$'], 'Arguments must be a JSON object.'); + } + const closedParameters = isPlainObject(parameters) + ? { ...parameters, additionalProperties: false } + : { type: 'object', properties: {}, additionalProperties: false }; + const failures = []; + validateValue(args, closedParameters, '$', failures); + if (failures.length) { + return validationFailure(toolName, failures, `Invalid or undeclared argument(s): ${[...new Set(failures)].join(', ')}.`); + } + if (toolName === 'click') { + const clickFailure = validateClickTarget(args); + if (clickFailure) return clickFailure; + } + return { ok: true, args }; +} diff --git a/src/firefox/src/agent/tools.js b/src/firefox/src/agent/tools.js index 966bb4942..a3a31bff1 100644 --- a/src/firefox/src/agent/tools.js +++ b/src/firefox/src/agent/tools.js @@ -1,3 +1,5 @@ +import { closeToolDefinitions } from './tool-arguments.js'; + /** * Tool definitions for the WebBrain agent. * These are sent to the LLM in OpenAI function-calling format. @@ -1163,15 +1165,15 @@ export function getToolsForMode(mode, opts = {}) { base = [...base, ...extras]; } const useDoneJson = normalizedMode === 'act' && tier === 'full' && opts.cloudRun === true && !!opts.outputSchema; - if (useDoneJson) return base.map(tool => (tool.function.name === 'done' ? DONE_JSON_TOOL : tool)); + if (useDoneJson) return closeToolDefinitions(base.map(tool => (tool.function.name === 'done' ? DONE_JSON_TOOL : tool))); const useOutcomeDone = normalizedMode !== 'ask'; - if (!opts.strictSecretMode && !useOutcomeDone) return base; + if (!opts.strictSecretMode && !useOutcomeDone) return closeToolDefinitions(base); const replacement = opts.strictSecretMode ? (useOutcomeDone ? (tier === 'compact' ? DONE_TOOL_COMPACT_STRICT_WITH_OUTCOME : DONE_TOOL_STRICT_WITH_OUTCOME) : DONE_TOOL_STRICT) : (tier === 'compact' ? DONE_TOOL_COMPACT_WITH_OUTCOME : DONE_TOOL_WITH_OUTCOME); - return base.map(t => (t.function.name === 'done' ? replacement : t)); + return closeToolDefinitions(base.map(t => (t.function.name === 'done' ? replacement : t))); } const SENSITIVE_PAGE_DATA_GUIDANCE = `SENSITIVE PAGE DATA: @@ -1180,7 +1182,9 @@ const SENSITIVE_PAGE_DATA_GUIDANCE = `SENSITIVE PAGE DATA: const PLAN_TO_EXECUTION_GUIDANCE = `PLAN TO EXECUTION: - In Act/Dev, an approved or pinned plan is context for doing the task, not a completed user outcome. When the user authorized action, do not end by returning the plan, planner JSON, action-policy metadata, or a promise to act; call the first permitted tool and continue until done, an explicit blocker, cancellation, or required user input. +- The trusted runtime mode is authoritative. Never claim that the run is in Ask mode or tell the user to switch to Act when the runtime prompt says Act/Dev. - Do not call done with the plan, planner JSON, action-policy metadata, or a promise to act as its summary. Call a permitted non-done tool first; use clarify or stop only for a real blocker or required user input. +- If a required form value is unavailable, leave that field untouched and call clarify. Never focus, clear, or write an empty value merely because the value is unknown. - Respect user boundaries: if the user asked only for a plan, or said to wait for approval or confirmation, return the plan or wait and do not execute. - Structured output can be legitimate user-requested data. Honor requested JSON or markdown formats; never treat an answer as leaked planner metadata merely because it looks like a plan or policy.`; diff --git a/src/firefox/src/background.js b/src/firefox/src/background.js index 68d1fba1e..ed29ee1f9 100644 --- a/src/firefox/src/background.js +++ b/src/firefox/src/background.js @@ -38,7 +38,7 @@ import { } from './context-menu-storage.js'; import { createTabChatHandoffCoordinator } from './ui/tab-chat-persistence.js'; import { normalizeOllamaLaunchHandoff } from './ollama-handoff.js'; -import { RunUiJournal, RunUiPersistenceScheduler, runUiSnapshotForRequest } from './run-ui-journal.js'; +import { RunUiJournal, RunUiPersistenceScheduler, compactRunUiSnapshotForPersist, runUiSnapshotForRequest } from './run-ui-journal.js'; import { USER_MEMORY_AUTO_CAPTURE_KEY, USER_MEMORY_ENABLED_KEY, @@ -1442,7 +1442,7 @@ function cloneRunUiSnapshot(snapshot) { function persistRunUiSnapshot(tabId, snapshot) { const requestId = String(snapshot?.requestId || ''); if (runUiPersistenceFailures.get(tabId) === requestId) return Promise.resolve(false); - const stableSnapshot = cloneRunUiSnapshot(snapshot); + const stableSnapshot = compactRunUiSnapshotForPersist(cloneRunUiSnapshot(snapshot)); const previous = runUiPersistenceQueues.get(tabId) || Promise.resolve(true); const write = previous.catch(() => false).then(async () => { if (runUiPersistenceFailures.get(tabId) === requestId) return false; @@ -1450,9 +1450,15 @@ function persistRunUiSnapshot(tabId, snapshot) { await browser.storage.session?.set({ [RUN_UI_PREFIX + tabId]: stableSnapshot }); return true; } catch { - runUiPersistenceFailures.set(tabId, requestId); - try { await browser.storage.session?.remove(RUN_UI_PREFIX + tabId); } catch {} - return false; + try { + await browser.storage.session?.set({ + [RUN_UI_PREFIX + tabId]: compactRunUiSnapshotForPersist(stableSnapshot, { tight: true }), + }); + return true; + } catch { + runUiPersistenceFailures.set(tabId, requestId); + return false; + } } }); runUiPersistenceQueues.set(tabId, write); @@ -2261,15 +2267,20 @@ async function handleMessage(msg, sender) { || (durabilityRequestId ? await agent.hasDurableSubmittedTurn(tabId, durabilityRequestId) : false); + const conversationState = await agent.getConversationState(tabId); + const activeState = agent.activeRunState(tabId); + const runUiDurable = !runUiSnapshot + || runUiPersistenceFailures.get(tabId) !== String(runUiSnapshot.requestId || ''); return { ok: true, - ...(await agent.getConversationState(tabId)), - ...agent.activeRunState(tabId), + ...conversationState, + ...activeState, starting: !!starting, startingRequestId: starting?.requestId || null, submittedTurnDurable, - runUiDurable: !runUiSnapshot - || runUiPersistenceFailures.get(tabId) !== String(runUiSnapshot.requestId || ''), + runUiDurable: runUiDurable, + persistenceDegraded: activeState.persistenceDegraded === true || !runUiDurable, + persistenceDegradedReason: activeState.persistenceDegradedReason || (!runUiDurable ? 'run_ui' : null), detachedError, runUi: requestedRunUi, }; diff --git a/src/firefox/src/error-format.js b/src/firefox/src/error-format.js new file mode 100644 index 000000000..00c2d411d --- /dev/null +++ b/src/firefox/src/error-format.js @@ -0,0 +1,55 @@ +const DEFAULT_ERROR_MESSAGE = 'An unexpected error occurred.'; +const PREFERRED_KEYS = ['message', 'error', 'detail', 'reason', 'description', 'cause', 'code', 'errorCode']; + +function bounded(text, maxLength) { + const value = String(text || '').trim(); + if (value === '[object Object]') return DEFAULT_ERROR_MESSAGE; + if (value.length <= maxLength) return value; + return `${value.slice(0, Math.max(0, maxLength - 14))}… [truncated]`; +} + +function stableJson(value, maxDepth = 4) { + const seen = new WeakSet(); + const visit = (item, depth) => { + if (item == null || typeof item !== 'object') return item; + if (seen.has(item)) return '[circular]'; + if (depth >= maxDepth) return Array.isArray(item) ? '[array omitted]' : '[object omitted]'; + seen.add(item); + if (Array.isArray(item)) return item.slice(0, 20).map(entry => visit(entry, depth + 1)); + const out = {}; + for (const key of Object.keys(item).sort().slice(0, 30)) out[key] = visit(item[key], depth + 1); + return out; + }; + try { + return JSON.stringify(visit(value, 0)); + } catch { + return ''; + } +} + +export function formatErrorMessage(value, options = {}) { + const maxLength = Number.isFinite(options.maxLength) ? Math.max(80, options.maxLength) : 2000; + const fallback = bounded(options.fallback || DEFAULT_ERROR_MESSAGE, maxLength) || DEFAULT_ERROR_MESSAGE; + const seen = new WeakSet(); + const find = (item, depth = 0) => { + if (typeof item === 'string' || typeof item === 'number' || typeof item === 'boolean') { + const text = bounded(item, maxLength); + return text && text !== '[object Object]' ? text : ''; + } + if (!item || typeof item !== 'object' || depth > 5 || seen.has(item)) return ''; + seen.add(item); + for (const key of PREFERRED_KEYS) { + if (!Object.prototype.hasOwnProperty.call(item, key)) continue; + const found = find(item[key], depth + 1); + if (found) return found; + } + return ''; + }; + const preferred = find(value); + if (preferred) return preferred; + if (value && typeof value === 'object') { + const json = bounded(stableJson(value), maxLength); + if (json && json !== '{}' && json !== '[]') return json; + } + return fallback; +} diff --git a/src/firefox/src/run-ui-journal.js b/src/firefox/src/run-ui-journal.js index 4ee28d21d..2e8daff3f 100644 --- a/src/firefox/src/run-ui-journal.js +++ b/src/firefox/src/run-ui-journal.js @@ -1,6 +1,8 @@ export const RUN_UI_EVENT_LIMIT = 256; export const RUN_UI_TEXT_DELTA_PERSIST_DELAY_MS = 200; export const RUN_UI_STREAM_TEXT_LIMIT = 100000; +export const RUN_UI_PERSIST_BUDGET = 512 * 1024; +export const RUN_UI_PERSIST_RETRY_BUDGET = 128 * 1024; /** * Highest sequence number that was genuinely evicted from the bounded replay @@ -62,6 +64,35 @@ export function compactRunUiData(type, data) { return data; } +export function compactRunUiSnapshotForPersist(snapshot, options = {}) { + const tight = options.tight === true; + const budget = tight ? RUN_UI_PERSIST_RETRY_BUDGET : RUN_UI_PERSIST_BUDGET; + const clone = typeof structuredClone === 'function' + ? structuredClone(snapshot || {}) + : JSON.parse(JSON.stringify(snapshot || {})); + clone.finalContent = String(clone.finalContent || '').slice(0, tight ? 8000 : 30000); + clone.streamedText = String(clone.streamedText || '').slice(0, tight ? 30000 : RUN_UI_STREAM_TEXT_LIMIT); + const eventCap = tight ? 64 : RUN_UI_EVENT_LIMIT; + clone.events = (Array.isArray(clone.events) ? clone.events : []).slice(-eventCap).map(event => { + const data = compactRunUiData(event?.type, event?.data); + if (tight && data && typeof data === 'object' && typeof data.content === 'string') { + data.content = data.content.slice(0, 4000); + } + return { ...event, data }; + }); + const removedBoundary = Number((Array.isArray(snapshot?.events) ? snapshot.events : []).at(-(clone.events.length + 1))?.seq || 0); + if (removedBoundary > 0) { + clone.discardedBeforeSeq = Math.max(runUiDiscardedBeforeSeq(clone), removedBoundary); + clone.truncatedBeforeSeq = clone.discardedBeforeSeq; + } + while (clone.events.length && JSON.stringify(clone).length > budget) { + const removed = clone.events.shift(); + clone.discardedBeforeSeq = Math.max(runUiDiscardedBeforeSeq(clone), Number(removed?.seq || 0)); + clone.truncatedBeforeSeq = clone.discardedBeforeSeq; + } + return clone; +} + export class RunUiPersistenceScheduler { constructor({ persist, diff --git a/src/firefox/src/trace/recorder.js b/src/firefox/src/trace/recorder.js index 18dd6a439..ad5084245 100644 --- a/src/firefox/src/trace/recorder.js +++ b/src/firefox/src/trace/recorder.js @@ -1,4 +1,5 @@ import { normalizeRuntimeTraceConfig } from './runtime-config.js'; +import { formatErrorMessage } from '../error-format.js'; /** * Trace recorder — writes per-run traces (LLM requests/responses, tool calls, @@ -231,7 +232,7 @@ export async function recordScreenshot(runId, step, dataUrl, caption = '') { } export function recordError(runId, step, phase, message) { - return _appendEvent(runId, 'error', { step, phase, message }); + return _appendEvent(runId, 'error', { step, phase, message: formatErrorMessage(message) }); } /** diff --git a/src/firefox/src/ui/locales/ar.js b/src/firefox/src/ui/locales/ar.js index 6410cd379..2a21fb218 100644 --- a/src/firefox/src/ui/locales/ar.js +++ b/src/firefox/src/ui/locales/ar.js @@ -153,6 +153,8 @@ export default { 'sp.copy': 'نسخ', 'sp.copied': 'تم النسخ!', 'sp.copy.code.title': 'نسخ الكود', + 'sp.copy.message.title': 'نسخ الرسالة', + 'sp.persistence.unavailable': 'تعذّر حفظ بيانات الاسترداد. يمكن متابعة المهمة الحالية، لكن لن تُعاد الإجراءات بعد انقطاع الاتصال. أعد المحاولة يدويًا.', 'sp.error_prefix': 'خطأ: {msg}', 'sp.subscribe.allowance_used': 'تم استخدام الحصة اليومية المجانية من WebBrain Cloud.', diff --git a/src/firefox/src/ui/locales/bn.js b/src/firefox/src/ui/locales/bn.js index 4da538d5c..191fd878f 100644 --- a/src/firefox/src/ui/locales/bn.js +++ b/src/firefox/src/ui/locales/bn.js @@ -254,6 +254,8 @@ export default { 'sp.copy': "কপি", 'sp.copied': "কপি করা !", 'sp.copy.code.title': "কোড কপি করুন", + 'sp.copy.message.title': "বার্তা কপি করুন", + 'sp.persistence.unavailable': "পুনরুদ্ধার ডেটা সংরক্ষণ করা যাচ্ছে না। চলমান কাজটি চালিয়ে যেতে পারে, তবে সংযোগ বিচ্ছিন্ন হলে কাজগুলো আবার চালানো হবে না। তখন নিজে পুনরায় চেষ্টা করুন।", 'sp.retry': "আবার চেষ্টা করুন", 'sp.retry.busy': "পুনরায় চেষ্টা করার আগে বর্তমান রান শেষ হওয়ার জন্য অপেক্ষা করুন।", 'sp.retry.attachments_unavailable': "ব্যর্থ প্রচেষ্টা থেকে সংযুক্তি আর উপলব্ধ নেই; শুধুমাত্র টেক্সট পুনরায় চেষ্টা.", diff --git a/src/firefox/src/ui/locales/de.js b/src/firefox/src/ui/locales/de.js index 8268ed9f5..18137255f 100644 --- a/src/firefox/src/ui/locales/de.js +++ b/src/firefox/src/ui/locales/de.js @@ -264,6 +264,8 @@ export default { 'sp.copy': 'Kopieren', 'sp.copied': 'Kopiert!', 'sp.copy.code.title': 'Code kopieren', + 'sp.copy.message.title': 'Nachricht kopieren', + 'sp.persistence.unavailable': 'Wiederherstellungsdaten können nicht gespeichert werden. Die laufende Aufgabe kann fortfahren, Aktionen werden nach einem Verbindungsabbruch jedoch nicht wiederholt. Versuche es dann manuell erneut.', 'sp.retry': 'Erneut versuchen', 'sp.retry.busy': 'Warten Sie, bis der aktuelle Durchlauf abgeschlossen ist, bevor Sie es erneut versuchen.', 'sp.retry.attachments_unavailable': 'Anhänge vom fehlgeschlagenen Versuch sind nicht mehr verfügbar; nur der Text wird erneut versucht.', diff --git a/src/firefox/src/ui/locales/en.js b/src/firefox/src/ui/locales/en.js index 352d0adab..ce5054548 100644 --- a/src/firefox/src/ui/locales/en.js +++ b/src/firefox/src/ui/locales/en.js @@ -254,6 +254,8 @@ export default { 'sp.copy': 'Copy', 'sp.copied': 'Copied!', 'sp.copy.code.title': 'Copy code', + 'sp.copy.message.title': 'Copy message', + 'sp.persistence.unavailable': 'Recovery persistence is unavailable. The live task can continue, but actions will not be replayed after a connection loss. Retry manually if disconnected.', 'sp.retry': 'Retry', 'sp.retry.busy': 'Wait for the current run to finish before retrying.', 'sp.retry.attachments_unavailable': 'Attachments from the failed attempt are no longer available; retrying the text only.', diff --git a/src/firefox/src/ui/locales/es.js b/src/firefox/src/ui/locales/es.js index c849bd78f..196d12f93 100644 --- a/src/firefox/src/ui/locales/es.js +++ b/src/firefox/src/ui/locales/es.js @@ -153,6 +153,8 @@ export default { 'sp.copy': 'Copiar', 'sp.copied': '¡Copiado!', 'sp.copy.code.title': 'Copiar código', + 'sp.copy.message.title': 'Copiar mensaje', + 'sp.persistence.unavailable': 'No se pueden guardar los datos de recuperación. La tarea activa puede continuar, pero las acciones no se repetirán tras una desconexión. Vuelve a intentarlo manualmente.', 'sp.error_prefix': 'Error: {msg}', 'sp.subscribe.allowance_used': 'Se agotó la asignación diaria gratuita de WebBrain Cloud.', diff --git a/src/firefox/src/ui/locales/fa.js b/src/firefox/src/ui/locales/fa.js index 9c9af35f8..733c8524d 100644 --- a/src/firefox/src/ui/locales/fa.js +++ b/src/firefox/src/ui/locales/fa.js @@ -254,6 +254,8 @@ export default { 'sp.copy': "کپی کنید", 'sp.copied': "کپی شده!", 'sp.copy.code.title': "کد را کپی کنید", + 'sp.copy.message.title': "پیام را کپی کنید", + 'sp.persistence.unavailable': "ذخیره‌سازی داده‌های بازیابی ممکن نیست. کار زنده می‌تواند ادامه یابد، اما پس از قطع اتصال هیچ عملی دوباره اجرا نمی‌شود. در آن صورت دستی دوباره تلاش کنید.", 'sp.retry': "دوباره امتحان کنید", 'sp.retry.busy': "قبل از تلاش مجدد منتظر بمانید تا اجرای فعلی به پایان برسد.", 'sp.retry.attachments_unavailable': "پیوست‌های حاصل از تلاش ناموفق دیگر در دسترس نیستند. فقط متن را دوباره امتحان کنید", diff --git a/src/firefox/src/ui/locales/fr.js b/src/firefox/src/ui/locales/fr.js index 74ff53ddb..7cfb9c6d0 100644 --- a/src/firefox/src/ui/locales/fr.js +++ b/src/firefox/src/ui/locales/fr.js @@ -153,6 +153,8 @@ export default { 'sp.copy': 'Copier', 'sp.copied': 'Copié !', 'sp.copy.code.title': 'Copier le code', + 'sp.copy.message.title': 'Copier le message', + 'sp.persistence.unavailable': 'Les données de récupération ne peuvent pas être enregistrées. La tâche en cours peut continuer, mais aucune action ne sera rejouée après une déconnexion. Réessayez manuellement.', 'sp.error_prefix': 'Erreur : {msg}', 'sp.subscribe.allowance_used': 'Quota quotidien gratuit de WebBrain Cloud épuisé.', diff --git a/src/firefox/src/ui/locales/he.js b/src/firefox/src/ui/locales/he.js index 80ad7a02e..4ee5d450c 100644 --- a/src/firefox/src/ui/locales/he.js +++ b/src/firefox/src/ui/locales/he.js @@ -239,6 +239,8 @@ export default { "sp.copy": "העתק", "sp.copied": "הועתק!", "sp.copy.code.title": "העתק קוד", + "sp.copy.message.title": "העתק הודעה", + "sp.persistence.unavailable": "לא ניתן לשמור נתוני שחזור. המשימה הפעילה יכולה להמשיך, אך פעולות לא יופעלו מחדש לאחר ניתוק. במקרה כזה יש לנסות שוב ידנית.", "sp.retry": "נסה שוב", "sp.retry.busy": "המתן עד שהריצה הנוכחית תסתיים לפני שתנסה שוב.", "sp.retry.attachments_unavailable": "קבצים מצורפים מהניסיון הכושל אינם זמינים עוד; מנסה שוב את הטקסט בלבד.", diff --git a/src/firefox/src/ui/locales/hi.js b/src/firefox/src/ui/locales/hi.js index 264db6336..f5bf717c3 100644 --- a/src/firefox/src/ui/locales/hi.js +++ b/src/firefox/src/ui/locales/hi.js @@ -254,6 +254,8 @@ export default { 'sp.copy': "प्रतिलिपि", 'sp.copied': "नकल की गई!", 'sp.copy.code.title': "कोड कॉपी करें", + 'sp.copy.message.title': "संदेश कॉपी करें", + 'sp.persistence.unavailable': "पुनर्प्राप्ति डेटा सहेजा नहीं जा सकता। चालू कार्य जारी रह सकता है, लेकिन कनेक्शन टूटने के बाद कार्रवाइयाँ दोबारा नहीं चलेंगी। तब मैन्युअल रूप से पुनः प्रयास करें।", 'sp.retry': "पुनः प्रयास करें", 'sp.retry.busy': "पुनः प्रयास करने से पहले वर्तमान रन समाप्त होने तक प्रतीक्षा करें।", 'sp.retry.attachments_unavailable': "असफल प्रयास के अनुलग्नक अब उपलब्ध नहीं हैं; केवल पाठ को पुनः प्रयास करना।", diff --git a/src/firefox/src/ui/locales/id.js b/src/firefox/src/ui/locales/id.js index 4cfc9cbdd..6704e4b06 100644 --- a/src/firefox/src/ui/locales/id.js +++ b/src/firefox/src/ui/locales/id.js @@ -153,6 +153,8 @@ export default { 'sp.copy': 'Salin', 'sp.copied': 'Tersalin!', 'sp.copy.code.title': 'Salin kode', + 'sp.copy.message.title': 'Salin pesan', + 'sp.persistence.unavailable': 'Data pemulihan tidak dapat disimpan. Tugas aktif dapat berlanjut, tetapi tindakan tidak akan diputar ulang setelah koneksi terputus. Coba lagi secara manual.', 'sp.error_prefix': 'Galat: {msg}', 'sp.subscribe.allowance_used': 'Kuota harian gratis WebBrain Cloud telah habis.', diff --git a/src/firefox/src/ui/locales/ja.js b/src/firefox/src/ui/locales/ja.js index 935be638f..b98f5948f 100644 --- a/src/firefox/src/ui/locales/ja.js +++ b/src/firefox/src/ui/locales/ja.js @@ -153,6 +153,8 @@ export default { 'sp.copy': 'コピー', 'sp.copied': 'コピーしました!', 'sp.copy.code.title': 'コードをコピー', + 'sp.copy.message.title': 'メッセージをコピー', + 'sp.persistence.unavailable': '復旧データを保存できません。実行中のタスクは続行できますが、接続が切れた後に操作を再実行することはありません。手動で再試行してください。', 'sp.error_prefix': 'エラー: {msg}', 'sp.subscribe.allowance_used': 'WebBrain Cloud の無料の1日あたりの利用枠を使い切りました。', diff --git a/src/firefox/src/ui/locales/ko.js b/src/firefox/src/ui/locales/ko.js index ece5a0ae0..94ba1823c 100644 --- a/src/firefox/src/ui/locales/ko.js +++ b/src/firefox/src/ui/locales/ko.js @@ -153,6 +153,8 @@ export default { 'sp.copy': '복사', 'sp.copied': '복사됨!', 'sp.copy.code.title': '코드 복사', + 'sp.copy.message.title': '메시지 복사', + 'sp.persistence.unavailable': '복구 데이터를 저장할 수 없습니다. 진행 중인 작업은 계속할 수 있지만 연결이 끊긴 뒤 작업을 다시 실행하지 않습니다. 수동으로 다시 시도하세요.', 'sp.error_prefix': '오류: {msg}', 'sp.subscribe.allowance_used': 'WebBrain Cloud의 무료 일일 사용량을 모두 사용했습니다.', diff --git a/src/firefox/src/ui/locales/ms.js b/src/firefox/src/ui/locales/ms.js index a1ab51a9d..759e2b9cf 100644 --- a/src/firefox/src/ui/locales/ms.js +++ b/src/firefox/src/ui/locales/ms.js @@ -153,6 +153,8 @@ export default { 'sp.copy': 'Salin', 'sp.copied': 'Disalin!', 'sp.copy.code.title': 'Salin kod', + 'sp.copy.message.title': 'Salin mesej', + 'sp.persistence.unavailable': 'Data pemulihan tidak dapat disimpan. Tugas langsung boleh diteruskan, tetapi tindakan tidak akan dimainkan semula selepas sambungan terputus. Cuba lagi secara manual.', 'sp.error_prefix': 'Ralat: {msg}', 'sp.subscribe.allowance_used': 'Peruntukan harian percuma WebBrain Cloud telah digunakan.', diff --git a/src/firefox/src/ui/locales/nl.js b/src/firefox/src/ui/locales/nl.js index 328b213e4..b8d1efa21 100644 --- a/src/firefox/src/ui/locales/nl.js +++ b/src/firefox/src/ui/locales/nl.js @@ -247,6 +247,8 @@ export default { 'sp.copy': 'Kopiëren', 'sp.copied': 'Gekopieerd!', 'sp.copy.code.title': 'Code kopiëren', + 'sp.copy.message.title': 'Bericht kopiëren', + 'sp.persistence.unavailable': 'Herstelgegevens kunnen niet worden opgeslagen. De actieve taak kan doorgaan, maar acties worden na een verbroken verbinding niet opnieuw uitgevoerd. Probeer het dan handmatig opnieuw.', 'sp.retry': 'Opnieuw proberen', 'sp.retry.busy': 'Wacht tot de huidige uitvoering is voltooid voordat u het opnieuw probeert.', 'sp.retry.attachments_unavailable': 'Bijlagen van de mislukte poging zijn niet meer beschikbaar; alleen de tekst wordt opnieuw geprobeerd.', diff --git a/src/firefox/src/ui/locales/pl.js b/src/firefox/src/ui/locales/pl.js index b840fe8c4..228c92667 100644 --- a/src/firefox/src/ui/locales/pl.js +++ b/src/firefox/src/ui/locales/pl.js @@ -200,6 +200,8 @@ export default { 'sp.copy': 'Kopiuj', 'sp.copied': 'Skopiowano!', 'sp.copy.code.title': 'Kopiuj kod', + 'sp.copy.message.title': 'Kopiuj wiadomość', + 'sp.persistence.unavailable': 'Nie można zapisać danych odzyskiwania. Bieżące zadanie może być kontynuowane, ale po utracie połączenia działania nie zostaną powtórzone. Spróbuj ponownie ręcznie.', 'sp.error_prefix': 'Błąd: {msg}', 'sp.subscribe.allowance_used': 'Wykorzystano dzienny darmowy limit WebBrain Cloud.', 'sp.subscribe.btn': 'Subskrybuj', diff --git a/src/firefox/src/ui/locales/pt.js b/src/firefox/src/ui/locales/pt.js index acc63dc4c..c72fd51d2 100644 --- a/src/firefox/src/ui/locales/pt.js +++ b/src/firefox/src/ui/locales/pt.js @@ -254,6 +254,8 @@ export default { 'sp.copy': "Copiar", 'sp.copied': "Copiado!", 'sp.copy.code.title': "Copiar código", + 'sp.copy.message.title': "Copiar mensagem", + 'sp.persistence.unavailable': "Não foi possível salvar os dados de recuperação. A tarefa ativa pode continuar, mas as ações não serão repetidas após uma desconexão. Tente novamente de forma manual.", 'sp.retry': "Tentar novamente", 'sp.retry.busy': "Aguarde a conclusão da execução atual antes de tentar novamente.", 'sp.retry.attachments_unavailable': "Os anexos da tentativa fracassada não estão mais disponíveis; repetindo apenas o texto.", diff --git a/src/firefox/src/ui/locales/ru.js b/src/firefox/src/ui/locales/ru.js index 52342c30b..504f2eca9 100644 --- a/src/firefox/src/ui/locales/ru.js +++ b/src/firefox/src/ui/locales/ru.js @@ -153,6 +153,8 @@ export default { 'sp.copy': 'Копировать', 'sp.copied': 'Скопировано!', 'sp.copy.code.title': 'Копировать код', + 'sp.copy.message.title': 'Копировать сообщение', + 'sp.persistence.unavailable': 'Не удалось сохранить данные восстановления. Текущая задача может продолжиться, но после разрыва соединения действия не будут повторены. Повторите попытку вручную.', 'sp.error_prefix': 'Ошибка: {msg}', 'sp.subscribe.allowance_used': 'Бесплатный дневной лимит WebBrain Cloud исчерпан.', diff --git a/src/firefox/src/ui/locales/th.js b/src/firefox/src/ui/locales/th.js index 735523944..d95f9d65e 100644 --- a/src/firefox/src/ui/locales/th.js +++ b/src/firefox/src/ui/locales/th.js @@ -153,6 +153,8 @@ export default { 'sp.copy': 'คัดลอก', 'sp.copied': 'คัดลอกแล้ว!', 'sp.copy.code.title': 'คัดลอกโค้ด', + 'sp.copy.message.title': 'คัดลอกข้อความ', + 'sp.persistence.unavailable': 'ไม่สามารถบันทึกข้อมูลการกู้คืนได้ งานที่กำลังทำยังดำเนินต่อได้ แต่จะไม่ทำซ้ำการกระทำหลังการเชื่อมต่อขาด โปรดลองใหม่ด้วยตนเอง', 'sp.error_prefix': 'ข้อผิดพลาด: {msg}', 'sp.subscribe.allowance_used': 'ใช้โควตารายวันฟรีของ WebBrain Cloud หมดแล้ว', diff --git a/src/firefox/src/ui/locales/tl.js b/src/firefox/src/ui/locales/tl.js index b12cb1e3b..c1f83e791 100644 --- a/src/firefox/src/ui/locales/tl.js +++ b/src/firefox/src/ui/locales/tl.js @@ -153,6 +153,8 @@ export default { 'sp.copy': 'Kopyahin', 'sp.copied': 'Nakopya!', 'sp.copy.code.title': 'Kopyahin ang code', + 'sp.copy.message.title': 'Kopyahin ang mensahe', + 'sp.persistence.unavailable': 'Hindi ma-save ang data sa pagbawi. Maaaring magpatuloy ang kasalukuyang gawain, ngunit hindi uulitin ang mga aksyon kapag naputol ang koneksyon. Subukang muli nang manu-mano.', 'sp.error_prefix': 'Error: {msg}', 'sp.subscribe.allowance_used': 'Naubos na ang libreng pang-araw-araw na alokasyon ng WebBrain Cloud.', diff --git a/src/firefox/src/ui/locales/tr.js b/src/firefox/src/ui/locales/tr.js index a19aaa277..efbc7b9e7 100644 --- a/src/firefox/src/ui/locales/tr.js +++ b/src/firefox/src/ui/locales/tr.js @@ -189,6 +189,8 @@ export default { 'sp.copy': 'Kopyala', 'sp.copied': 'Kopyalandı!', 'sp.copy.code.title': 'Kodu kopyala', + 'sp.copy.message.title': 'Mesajı kopyala', + 'sp.persistence.unavailable': 'Kurtarma verileri kaydedilemiyor. Canlı görev devam edebilir ancak bağlantı koparsa işlemler yeniden oynatılmaz; elle yeniden deneyin.', 'sp.error_prefix': 'Hata: {msg}', 'sp.subscribe.allowance_used': 'Ücretsiz günlük WebBrain Cloud kullanım hakkınız doldu.', diff --git a/src/firefox/src/ui/locales/uk.js b/src/firefox/src/ui/locales/uk.js index 06a70eca6..9687a3429 100644 --- a/src/firefox/src/ui/locales/uk.js +++ b/src/firefox/src/ui/locales/uk.js @@ -153,6 +153,8 @@ export default { 'sp.copy': 'Копіювати', 'sp.copied': 'Скопійовано!', 'sp.copy.code.title': 'Копіювати код', + 'sp.copy.message.title': 'Копіювати повідомлення', + 'sp.persistence.unavailable': 'Не вдалося зберегти дані відновлення. Поточне завдання може продовжитися, але після розриву з’єднання дії не повторюватимуться. Спробуйте знову вручну.', 'sp.error_prefix': 'Помилка: {msg}', 'sp.subscribe.allowance_used': 'Безкоштовний денний ліміт WebBrain Cloud вичерпано.', diff --git a/src/firefox/src/ui/locales/vi.js b/src/firefox/src/ui/locales/vi.js index aeda1c3c4..9dd125327 100644 --- a/src/firefox/src/ui/locales/vi.js +++ b/src/firefox/src/ui/locales/vi.js @@ -254,6 +254,8 @@ export default { 'sp.copy': "Sao chép", 'sp.copied': "Đã sao chép!", 'sp.copy.code.title': "Sao chép mã", + 'sp.copy.message.title': "Sao chép tin nhắn", + 'sp.persistence.unavailable': "Không thể lưu dữ liệu khôi phục. Tác vụ đang chạy có thể tiếp tục, nhưng các hành động sẽ không được phát lại sau khi mất kết nối. Hãy thử lại theo cách thủ công.", 'sp.retry': "Thử lại", 'sp.retry.busy': "Đợi quá trình chạy hiện tại kết thúc trước khi thử lại.", 'sp.retry.attachments_unavailable': "Tệp đính kèm từ lần thử không thành công không còn tồn tại nữa; chỉ thử lại văn bản.", diff --git a/src/firefox/src/ui/locales/zh.js b/src/firefox/src/ui/locales/zh.js index 587f62ed5..741bf94c8 100644 --- a/src/firefox/src/ui/locales/zh.js +++ b/src/firefox/src/ui/locales/zh.js @@ -153,6 +153,8 @@ export default { 'sp.copy': '复制', 'sp.copied': '已复制!', 'sp.copy.code.title': '复制代码', + 'sp.copy.message.title': '复制消息', + 'sp.persistence.unavailable': '无法保存恢复数据。当前任务可以继续,但连接中断后不会重放任何操作;请手动重试。', 'sp.error_prefix': '错误:{msg}', 'sp.subscribe.allowance_used': '今日免费的 WebBrain Cloud 额度已用完。', diff --git a/src/firefox/src/ui/settings.js b/src/firefox/src/ui/settings.js index 6c55a1c01..954bef36e 100644 --- a/src/firefox/src/ui/settings.js +++ b/src/firefox/src/ui/settings.js @@ -52,7 +52,7 @@ import { ADDITIONAL_PROVIDER_UI } from '../providers/provider-catalog.js'; // Version shown in the subtitle. Kept here so it only needs one update per // release; the subtitle string itself is translated. -const EXT_VERSION = '26.0.10'; +const EXT_VERSION = '26.0.11'; const providersContainer = document.getElementById('providers'); const displaySettings = document.getElementById('display-settings'); diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index 75d9d8815..a6d48625c 100644 --- a/src/firefox/src/ui/sidepanel.js +++ b/src/firefox/src/ui/sidepanel.js @@ -23,6 +23,7 @@ import { historyTextFromElement } from './history-text.js'; import { claimRunError } from './run-error-dedupe.js'; import { RUN_CAPTURE_START_ERROR_PREFIX } from '../run-capture.js'; import { runUiUnavailableBeforeSeq } from '../run-ui-journal.js'; +import { formatErrorMessage } from '../error-format.js'; import { escapeHtml } from './utils.js'; import { isBackgroundConnectionError, @@ -5485,7 +5486,7 @@ function scheduleActiveChatPayloadCleanup(tabId, state) { } function renderAgentErrorUpdate(data, tabId = currentTabId, requestId = '', options = {}) { - const message = data?.message || data?.error || 'unknown error'; + const message = formatErrorMessage(data?.message ?? data?.error ?? data); // Allowance routing depends on terminal durable-turn proof. Live error // updates arrive before that proof, so the run_complete/direct response // path owns the single actionable card. @@ -7687,6 +7688,8 @@ function handleAgentUpdateMessage(msg) { renderPlannerRequestFailure(targetAssistantEl, data, retryPayload); } else if (data?.code === 'ask_stream_fallback') { showComposerToast(t('sp.streaming.fallback'), { duration: 6000 }); + } else if (data?.code === 'persistence_degraded') { + showComposerToast(t('sp.persistence.unavailable'), { duration: 10000 }); } break; @@ -8761,9 +8764,25 @@ function clearTransientAssistantTextForToolCall() { function appendVerboseToolCall(name, args) { if (!currentAssistantEl) return; const content = currentAssistantEl.querySelector('.message-content'); + content.querySelectorAll('.tool-call[data-awaiting-result="true"]').forEach(tool => { + tool.dataset.awaitingResult = 'false'; + }); + + if (name === 'done') { + const priorRejected = content.querySelector('.tool-call[data-tool-name="done"][data-rejected-completion="true"]'); + if (priorRejected) { + priorRejected.querySelector('.tool-call-body').textContent = JSON.stringify(args, null, 2); + priorRejected.querySelector('.tool-result')?.remove(); + priorRejected.dataset.rejectedCompletion = 'pending'; + priorRejected.dataset.awaitingResult = 'true'; + return; + } + } const el = document.createElement('div'); el.className = 'tool-call'; + el.dataset.toolName = name || ''; + el.dataset.awaitingResult = 'true'; const header = document.createElement('div'); header.className = 'tool-call-header'; @@ -8786,12 +8805,16 @@ function appendVerboseToolCall(name, args) { function appendVerboseToolResult(name, result) { if (!currentAssistantEl) return; const content = currentAssistantEl.querySelector('.message-content'); - const lastTool = content.querySelector('.tool-call:last-of-type'); + const lastTool = content.querySelector('.tool-call[data-awaiting-result="true"]'); if (lastTool) { const resultEl = document.createElement('div'); resultEl.className = 'tool-result'; resultEl.textContent = truncate(JSON.stringify(result), 200); lastTool.appendChild(resultEl); + if (name === 'done') { + lastTool.dataset.rejectedCompletion = result?.blockedDone === true ? 'true' : 'false'; + } + lastTool.dataset.awaitingResult = 'false'; } } @@ -9767,12 +9790,18 @@ function addMessageCopyButton(msgEl) { if (!msgEl) return; const content = msgEl.querySelector('.message-content'); if (!content) return; + const existing = content.querySelector('.msg-copy-btn:not(.scratchpad-copy-btn)'); + if (existing) { + bindMessageCopyButton(existing); + return existing; + } const btn = document.createElement('button'); btn.className = 'msg-copy-btn'; btn.textContent = t('sp.copy'); - btn.title = t('sp.copy.code.title'); + btn.title = t('sp.copy.message.title'); bindMessageCopyButton(btn); content.appendChild(btn); + return btn; } function addScratchpadCopyButton(msgEl) { @@ -9780,12 +9809,18 @@ function addScratchpadCopyButton(msgEl) { const content = msgEl.querySelector('.message-content'); const pre = content?.querySelector('pre.scratchpad-dump'); if (!content || !pre) return; + const existing = content.querySelector('.scratchpad-copy-btn'); + if (existing) { + bindMessageCopyButton(existing); + return existing; + } const btn = document.createElement('button'); btn.className = 'msg-copy-btn scratchpad-copy-btn'; btn.textContent = t('sp.copy'); btn.title = t('sp.copy.code.title'); bindMessageCopyButton(btn); content.appendChild(btn); + return btn; } function getMessageCopyText(btn) { diff --git a/src/firefox/src/ui/tab-chat-persistence.js b/src/firefox/src/ui/tab-chat-persistence.js index a3c866284..09c234347 100644 --- a/src/firefox/src/ui/tab-chat-persistence.js +++ b/src/firefox/src/ui/tab-chat-persistence.js @@ -141,45 +141,6 @@ export async function persistTabChatToSession(storageArea, key, html, warn = con retryError = error; } - try { - // Older per-tab chats can consume nearly the entire shared quota. Free - // the largest stored chats one at a time and retry after each removal. - // Removal is intentionally used instead of rewriting a stale get(null) - // snapshot: a concurrent clear remains cleared rather than being - // resurrected by quota recovery in another panel context. - const stored = await storageArea.get(null); - const candidates = Object.entries(stored || {}) - .filter(([storedKey, value]) => ( - storedKey !== key - && storedKey.startsWith(TAB_CHAT_PREFIX) - && typeof value === 'string' - )) - .sort((a, b) => b[1].length - a[1].length); - const evictedKeys = []; - - for (const [storedKey] of candidates) { - try { - await storageArea.remove(storedKey); - evictedKeys.push(storedKey); - } catch { - continue; - } - try { - await storageArea.set({ [key]: retryValue }); - return { - ok: true, - degraded: true, - recoveredFromQuota: true, - evictedKeys, - }; - } catch (error) { - retryError = error; - } - } - } catch (error) { - retryError = error; - } - try { warn( '[WebBrain] persistTabChat: session storage write failed after compacting the stored copy; chat may not survive a panel reopen:', diff --git a/test/run.js b/test/run.js index e718b0b70..0786179e7 100644 --- a/test/run.js +++ b/test/run.js @@ -7995,7 +7995,7 @@ test('mutation batch invokes CAPTCHA preflight before dispatch when no gate exis for (const [label, AgentClass] of [['chrome', AgentCh], ['firefox', AgentFx]]) { for (const [toolName, toolArguments, allowedTools] of [ ['click_ax', '{"ref_id":"ref_9"}', new Set(['click_ax', 'solve_captcha'])], - ['done', '{"success":true}', new Set(['done', 'solve_captcha'])], + ['done', '{"summary":"Done.","outcome":"success"}', new Set(['done', 'solve_captcha'])], ['fetch_url', '{"url":"https://example.test/signup","method":"POST"}', new Set(['fetch_url', 'solve_captcha'])], ]) { const agent = new AgentClass({ getVisionProvider: async () => null }); @@ -20305,7 +20305,7 @@ test('tab-chat persistence recovers when several sub-threshold chats exceed the } }); -test('tab-chat persistence evicts an existing chat when older keys saturate the shared quota', async () => { +test('tab-chat persistence never evicts other chats when shared quota remains exhausted', async () => { for (const [label, persistence] of [ ['chrome', TabChatPersistenceCh], ['firefox', TabChatPersistenceFx], @@ -20343,14 +20343,11 @@ test('tab-chat persistence evicts an existing chat when older keys saturate the (...args) => warnings.push(args), ); - assert.equal(result.ok, true, `${label}: saturated shared quota should recover`); - assert.equal(result.recoveredFromQuota, true, `${label}: recovery marker missing`); - assert.deepEqual(result.evictedKeys, [oldKey], `${label}: recovery should report the evicted chat`); - assert.equal(values[oldKey], undefined, `${label}: older chat should be evicted to free quota`); - assert.equal(typeof values[newKey], 'string', `${label}: current compacted chat should persist`); - assert.ok(values[newKey].length <= 256 * 1024, `${label}: recovered chat should remain tightly bounded`); + assert.equal(result.ok, false, `${label}: saturated shared quota should report non-durable persistence`); + assert.equal(values[oldKey]?.length > 0, true, `${label}: another tab's chat must not be evicted`); + assert.equal(values[newKey], undefined, `${label}: failed current snapshot should not be advertised as stored`); assert.equal(values.unrelatedSessionState, 'keep', `${label}: non-chat session state must not be evicted`); - assert.equal(warnings.length, 0, `${label}: successful recovery should not warn`); + assert.equal(warnings.length, 1, `${label}: failed bounded persistence should warn once`); } }); @@ -36064,7 +36061,7 @@ test('Agent tool loops preserve provider reasoning state on both execution paths assert.match(source, /_withResponseItems\(message, responseItems, reasoningContent = '', provider = null\)[\s\S]*response_items: responseItems/, `${prefix}: assistant helper should retain Responses output Items`); assert.match(source, /_expireCurrentToolReasoning\(messages\)/, `${prefix}: new user turns should expire immediate-only reasoning replay`); assert.match(source, /reasoning_content: reasoningContent/, `${prefix}: assistant helper should retain Chat Completions reasoning content`); - assert.match(source, /content: result\.content \|\| null,[\s\S]*tool_calls: result\.toolCalls,[\s\S]*}, result\.responseItems, result\.reasoningContent, provider\)/, `${prefix}: non-stream tool loop should retain provider reasoning state`); + assert.match(source, /content: assistantToolContent,[\s\S]*tool_calls: result\.toolCalls,[\s\S]*}, result\.responseItems, result\.reasoningContent, provider\)/, `${prefix}: non-stream tool loop should retain provider reasoning state`); assert.match(source, /content: result\.content \}, result\.responseItems, result\.reasoningContent, provider\)[\s\S]*messages\.push\(\{ role: 'user', content: plainFinalBlocks\.join/, `${prefix}: non-stream progress continuations should scope provider reasoning state`); assert.match(source, /content: finalResponse \}, result\.responseItems, result\.reasoningContent, provider\)/, `${prefix}: non-stream final answers should scope provider reasoning state`); assert.match(source, /chunk\.type === 'reasoning'[\s\S]*content: fullText \|\| null,[\s\S]*tool_calls: toolCalls,[\s\S]*}, responseItems, reasoningContent, provider\)/, `${prefix}: stream tool loop should retain provider reasoning state`); @@ -39507,7 +39504,7 @@ test('agent stops prompting current tool after permission gate is disabled mid-p id: 'tool_1', function: { name: 'set_field', - arguments: '{"selector":"input[name=email]","value":"a@example.com","submit":true}', + arguments: '{"ref_id":"ref_email","text":"a@example.com","submit":true}', }, }], messages, @@ -40574,7 +40571,6 @@ ${JSON.stringify([ name: 'download_custom_media', arguments: JSON.stringify({ mediaUrl: 'https://media.example/assets/video.mp4', - url: 'https://trusted.example/decoy.mp4', }), }, }], @@ -43658,7 +43654,7 @@ test('nullish tool responses classify consequential outcomes and stop unsafe bat ['fetch_url', { url: 'https://api.example.com/items', method: 'POST', body: '{}' }, true], ['iframe_click', { selector: '#submit', urlFilter: 'payments.example.com' }, true], ['download_file', { url: 'https://example.com/report.pdf' }, true], - ['schedule_task', { title: 'Check later', prompt: 'Check status later', schedule: { after_seconds: 60 } }, true], + ['schedule_task', { title: 'Check later', prompt: 'Check status later', schedule: { type: 'once', after_seconds: 60 }, target: { type: 'current_tab' } }, true], ]) { const agent = new AgentClass({ getActive: () => ({ contextWindow: 128000, supportsVision: false }), @@ -43994,7 +43990,7 @@ test('streaming and non-streaming paths share the hardened batch executor', () = test('tool-result limiting is nullish-safe and preserves serializable falsy values', () => { for (const AgentClass of [AgentCh, AgentFx]) { - const agent = new AgentClass({ getActive: () => ({ contextWindow: 128000, supportsVision: false }) }); + const agent = new AgentClass({ getActive: () => ({ contextWindow: 128000, supportsVision: false }), getVisionProvider: async () => null }); for (const value of [undefined, null]) { const parsed = JSON.parse(agent._limitToolResult(value)); assert.equal(parsed.errorCode, 'missing_tool_response', `${AgentClass.name}: nullish result was not normalized`); @@ -62723,4 +62719,240 @@ test('capsolver errors: demo-key refusals and task-config errors get different r } }); +test('built-in tool schemas are closed and invalid arguments never dispatch', async () => { + for (const [label, AgentClass] of [['chrome', AgentCh], ['firefox', AgentFx]]) { + const toolsModule = await import(pathToFileURL(path.join(ROOT, `src/${label}/src/agent/tools.js`)).href); + const argumentModule = await import(pathToFileURL(path.join(ROOT, `src/${label}/src/agent/tool-arguments.js`)).href); + const advertised = toolsModule.getToolsForMode('act'); + assert.ok(advertised.length > 0, `${label}: Act tool catalog is empty`); + assert.ok( + advertised.every(tool => tool.function?.parameters?.additionalProperties === false), + `${label}: an advertised object schema remains open`, + ); + + const setField = toolsModule.AGENT_TOOLS.find(tool => tool.function?.name === 'set_field'); + const rejectedLang = argumentModule.validateToolArguments( + 'set_field', + { ref_id: 'ref_1', text: 'miras.global', clear: true, submit: false, lang: 'tr-deasciify' }, + setField.function.parameters, + ); + assert.equal(rejectedLang.ok, false, `${label}: undeclared lang argument was accepted`); + assert.equal(rejectedLang.result.noDispatch, true, `${label}: rejected lang argument did not fail closed`); + assert.equal(rejectedLang.result.errorCode, 'invalid_tool_arguments', `${label}: unstable invalid-argument code`); + + const click = toolsModule.AGENT_TOOLS.find(tool => tool.function?.name === 'click'); + for (const args of [ + { index: 3, x: 0, y: 0 }, + { text: 'Save', selector: '#save' }, + { x: 10 }, + ]) { + const rejectedClick = argumentModule.validateToolArguments('click', args, click.function.parameters); + assert.equal(rejectedClick.ok, false, `${label}: mixed/incomplete click target was accepted`); + assert.equal(rejectedClick.result.noDispatch, true, `${label}: invalid click target did not fail closed`); + } + assert.equal( + argumentModule.validateToolArguments('click', { index: 3 }, click.function.parameters).ok, + true, + `${label}: one valid click strategy was rejected`, + ); + + const agent = new AgentClass({ + getActive: () => ({ contextWindow: 128000, supportsVision: false }), + getVisionProvider: async () => null, + }); + const tabId = label === 'chrome' ? 9901 : 9902; + agent.conversationModes.set(tabId, 'act'); + agent._persist = () => {}; + let dispatches = 0; + agent.executeTool = async () => { + dispatches++; + return { success: true }; + }; + const messages = []; + const updates = []; + await agent._executeToolBatch( + tabId, + [{ id: 'bad_lang', function: { name: 'set_field', arguments: JSON.stringify({ ref_id: 'ref_1', text: 'miras.global', lang: 'tr-deasciify' }) } }], + messages, + (type, data) => updates.push({ type, data }), + { supportsVision: false }, + null, + new Set(['set_field']), + 1, + ); + assert.equal(dispatches, 0, `${label}: invalid set_field arguments reached executeTool`); + const result = JSON.parse(messages.find(message => message.role === 'tool')?.content || '{}'); + assert.equal(result.invalidArguments, true, `${label}: structured invalidArguments marker missing`); + assert.equal(result.noDispatch, true, `${label}: structured noDispatch marker missing`); + assert.ok(updates.some(update => update.type === 'tool_result' && update.data?.result?.noDispatch === true), `${label}: UI did not receive the rejected tool result`); + } +}); + +test('execute protocol suppresses planner payloads, rejects false Ask claims, and ends with friendly failure', async () => { + const plannerPayload = JSON.stringify({ + request_kind: 'execute', + requires_state_change: true, + summary: 'Fill the form.', + steps: [{ id: '1', action: 'Type the value.' }], + localized: { locale: 'tr', summary: 'Formu doldur.', steps: [] }, + }); + for (const AgentClass of [AgentCh, AgentFx]) { + const agent = new AgentClass({ getActive: () => ({ contextWindow: 128000, supportsVision: false }) }); + const tabId = AgentClass === AgentCh ? 9911 : 9912; + assert.equal(agent._isPlannerShapedJson(plannerPayload), true, `${AgentClass.name}: planner JSON was not detected`); + agent._startPlanExecutionGuard(tabId, 'act', { requestKind: 'execute', requiresStateChange: false }); + assert.equal( + agent._looksLikePlanOnlyTerminal('You are currently in Ask mode. Switch to Act mode to continue.', agent._planExecutionGuards.get(tabId)), + true, + `${AgentClass.name}: false runtime-mode claim was accepted`, + ); + const first = agent._planOnlyTerminalDecision(tabId, plannerPayload); + assert.equal(first?.retry, true, `${AgentClass.name}: first planner terminal did not receive one recovery`); + const second = agent._planOnlyTerminalDecision(tabId, plannerPayload); + assert.match(second?.failure || '', /could not verify any requested page action/i, `${AgentClass.name}: terminal failure is not user-facing`); + assert.doesNotMatch(second?.failure || '', /execute protocol|recovery nudge|model returned/i, `${AgentClass.name}: raw protocol detail leaked to terminal`); + } +}); + +test('planner routes Act advice follow-ups to respond and protects unknown required form values', () => { + for (const build of ['chrome', 'firefox']) { + const planner = fs.readFileSync(path.join(ROOT, `src/${build}/src/agent/planner.js`), 'utf8'); + assert.match(planner, /Runtime mode does not force execute/, `${build}: Act advice routing rule missing`); + assert.match(planner, /trusted conversation context already contains everything needed/, `${build}: conversation-only respond rule missing`); + assert.match(planner, /required form value is unavailable[\s\S]*?leave the field untouched/, `${build}: missing form-value guard missing`); + } +}); + +test('error formatting is bounded and never exposes object coercion text', async () => { + for (const build of ['chrome', 'firefox']) { + const { formatErrorMessage } = await import(pathToFileURL(path.join(ROOT, `src/${build}/src/error-format.js`)).href); + assert.equal(formatErrorMessage({ error: { detail: { message: 'Provider rejected the request.' } } }), 'Provider rejected the request.', `${build}: nested provider message missing`); + assert.equal(formatErrorMessage(new Error('Network unavailable.')), 'Network unavailable.', `${build}: Error instance message missing`); + assert.equal(formatErrorMessage({ error: { cause: new Error('Nested provider failure.') } }), 'Nested provider failure.', `${build}: nested Error cause message missing`); + const arbitrary = formatErrorMessage({ status: 503, payload: { retry: false } }); + assert.doesNotMatch(arbitrary, /\[object Object\]/, `${build}: arbitrary object leaked coercion text`); + assert.match(arbitrary, /"status":503/, `${build}: arbitrary object did not receive a useful stable representation`); + const circular = { code: 'E_LOOP' }; + circular.self = circular; + assert.doesNotMatch(formatErrorMessage(circular), /\[object Object\]/, `${build}: circular error leaked coercion text`); + assert.ok(formatErrorMessage({ message: 'x'.repeat(5000) }, { maxLength: 200 }).length <= 200, `${build}: formatter ignored its bound`); + + const panel = fs.readFileSync(path.join(ROOT, `src/${build}/src/ui/sidepanel.js`), 'utf8'); + assert.match(panel, /function renderAgentErrorUpdate[\s\S]*?formatErrorMessage\(data\?\.message \?\? data\?\.error \?\? data\)/, `${build}: UI error ingress is not normalized`); + assert.match(panel, /const existing = content\.querySelector\('\.msg-copy-btn:not\(\.scratchpad-copy-btn\)'\)/, `${build}: message Copy insertion is not idempotent`); + assert.match(panel, /btn\.title = t\('sp\.copy\.message\.title'\)/, `${build}: message Copy still reuses the code tooltip`); + assert.match(panel, /data-rejected-completion[\s\S]*?rejectedCompletion = 'pending'/, `${build}: rejected done retries are not collapsed`); + } +}); + +test('session conversation snapshots strip binary payloads and cap large tool results', async () => { + const imagePayload = 'A'.repeat(3 * 1024 * 1024); + for (const build of ['chrome', 'firefox']) { + const persistence = await import(pathToFileURL(path.join(ROOT, `src/${build}/src/agent/conversation-persistence.js`)).href); + const messages = [ + { role: 'system', content: 'system' }, + { + role: 'user', + attachmentHandles: [{ attachmentId: 'attachment_safe_1', kind: 'image', name: 'fixture.png' }], + content: [ + { type: 'text', text: 'Use this attachment.' }, + { type: 'image_url', image_url: { url: `data:image/png;base64,${imagePayload}` } }, + ], + }, + { role: 'tool', tool_call_id: 'shot', content: JSON.stringify({ image: `data:image/png;base64,${imagePayload}`, tree: 'x'.repeat(200_000) }) }, + { role: 'user', transientCompletionVerification: true, content: [{ type: 'image_url', image_url: { url: `data:image/png;base64,${imagePayload}` } }] }, + ]; + const serialized = persistence.serializeConversationForSession(messages); + const json = JSON.stringify(serialized.messages); + assert.equal(serialized.compacted, true, `${build}: binary snapshot was not marked compacted`); + assert.ok(serialized.bytes <= persistence.SESSION_CONVERSATION_BUDGET_BYTES, `${build}: serialized conversation exceeded its bound`); + assert.doesNotMatch(json, /data:image\//, `${build}: inline image survived session serialization`); + assert.doesNotMatch(json, new RegExp(`A{${1024 * 1024}}`), `${build}: multi-megabyte payload survived session serialization`); + assert.match(json, /attachment_safe_1/, `${build}: user attachment durable handle was lost`); + assert.match(json, /Completion verification screenshot omitted/, `${build}: completion screenshot placeholder missing`); + } +}); + +test('quota exhaustion degrades recovery once, continues live, and disables automatic replay', async () => { + const previousChrome = globalThis.chrome; + const previousBrowser = globalThis.browser; + try { + for (const [label, AgentClass, apiName, tabId] of [ + ['chrome', AgentCh, 'chrome', 9931], + ['firefox', AgentFx, 'browser', 9932], + ]) { + let writes = 0; + globalThis[apiName] = { + storage: { + session: { + get: async () => ({}), + set: async () => { + writes++; + throw new Error('QUOTA_BYTES exceeded'); + }, + }, + }, + }; + const agent = new AgentClass({}); + agent.conversations.set(tabId, [{ role: 'system', content: 'system' }, { role: 'user', content: 'continue live' }]); + agent.submittedRunRequestIds.set(tabId, `${label}-request`); + agent.currentRunId.set(tabId, `${label}-run`); + const updates = []; + agent._runUpdateCallbacks.set(tabId, (type, data) => updates.push({ type, data })); + + const first = await agent._persistNow(tabId); + const second = await agent._persistNow(tabId); + assert.deepEqual( + { ok: first.ok, degraded: first.degraded, reason: first.reason }, + { ok: false, degraded: true, reason: 'quota' }, + `${label}: quota failure did not return structured degradation`, + ); + assert.equal(second.ok, false, `${label}: repeated quota write unexpectedly became durable`); + assert.equal(writes, 4, `${label}: each write should make one compact retry and stop`); + assert.equal(updates.filter(update => update.data?.code === 'persistence_degraded').length, 1, `${label}: degradation warning was not deduped per run`); + assert.equal(agent.activeRunState(tabId).persistenceDegraded, true, `${label}: active run state omitted degradation`); + assert.equal(await agent.hasDurableSubmittedTurn(tabId, `${label}-request`), false, `${label}: non-durable run remained replayable`); + assert.equal(agent.conversations.get(tabId).at(-1).content, 'continue live', `${label}: live conversation was discarded`); + + let retryWrites = 0; + let compactStored = null; + globalThis[apiName].storage.session.set = async patch => { + retryWrites++; + if (retryWrites === 1) throw new Error('QUOTA_BYTES exceeded'); + compactStored = patch; + }; + const recovered = new AgentClass({}); + recovered.conversations.set(tabId, [{ role: 'system', content: 'system' }, { role: 'tool', content: 'x'.repeat(600_000) }]); + const persisted = await recovered._persistNow(tabId); + assert.equal(persisted.ok, true, `${label}: compact retry did not recover`); + assert.equal(persisted.reason, 'quota_compacted', `${label}: compact retry reason missing`); + assert.ok(JSON.stringify(compactStored).length < 500_000, `${label}: retry snapshot was not tightly bounded`); + } + } finally { + if (previousChrome === undefined) delete globalThis.chrome; + else globalThis.chrome = previousChrome; + if (previousBrowser === undefined) delete globalThis.browser; + else globalThis.browser = previousBrowser; + } +}); + +test('run UI persistence compaction preserves acknowledged versus discarded boundaries', async () => { + for (const build of ['chrome', 'firefox']) { + const journal = await import(pathToFileURL(path.join(ROOT, `src/${build}/src/run-ui-journal.js`)).href); + const events = Array.from({ length: 120 }, (_, index) => ({ + seq: index + 1, + type: 'text_delta', + data: { content: 'x'.repeat(10_000) }, + ts: index + 1, + })); + const snapshot = { requestId: 'bounded-run', seq: 120, ackedSeq: 10, discardedBeforeSeq: 0, events, streamedText: 'x'.repeat(100_000), finalContent: '' }; + const compact = journal.compactRunUiSnapshotForPersist(snapshot, { tight: true }); + assert.ok(JSON.stringify(compact).length <= journal.RUN_UI_PERSIST_RETRY_BUDGET, `${build}: run UI retry snapshot exceeded budget`); + assert.equal(compact.ackedSeq, 10, `${build}: compaction rewrote acknowledged boundary`); + assert.ok(compact.discardedBeforeSeq > compact.ackedSeq, `${build}: genuine persisted eviction did not create a replay-gap boundary`); + const acknowledgedOnly = { ackedSeq: 42, discardedBeforeSeq: 0, truncatedBeforeSeq: 42 }; + assert.equal(journal.runUiDiscardedBeforeSeq(acknowledgedOnly), 0, `${build}: acknowledged events became a false replay gap`); + } +}); + await run();