From 98fcf9cc6097cf3b7e048eb66f627299f3a51021 Mon Sep 17 00:00:00 2001 From: fly1d Date: Wed, 12 Aug 2026 11:57:06 +0800 Subject: [PATCH] fix(planner): require structured download completion evidence --- src/chrome/src/agent/agent.js | 150 +++++++- src/chrome/src/agent/planner.js | 34 +- src/chrome/src/network/network-tools.js | 14 +- src/firefox/src/agent/agent.js | 150 +++++++- src/firefox/src/agent/planner.js | 34 +- src/firefox/src/network/network-tools.js | 14 +- test/run.js | 416 ++++++++++++++++++++++- 7 files changed, 777 insertions(+), 35 deletions(-) diff --git a/src/chrome/src/agent/agent.js b/src/chrome/src/agent/agent.js index 47e51f538..187aa22c6 100644 --- a/src/chrome/src/agent/agent.js +++ b/src/chrome/src/agent/agent.js @@ -5942,6 +5942,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d if (fnName !== 'done') { this._markPlanExecutionToolCall(tabId, fnName, toolResult, { consequential: executionMutationEvidence, + download: capabilities.includes(Capability.DOWNLOAD), }); } const completionStateBeforeTool = this.completionInvariants.get(tabId) || null; @@ -9497,6 +9498,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d proceed: true, requestKind: 'execute', requiresStateChange: true, + requiresDownload: fastPathPlan.id === 'download-media', progressLedgerPolicy: 'auto', progressAction: null, }; @@ -9934,6 +9936,23 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d }; } + _plannerCompletionGateFields(plan) { + return { + requiresDownload: plan?.completion_requirements?.download === true, + }; + } + + async _tracePlannerCompletionRequirementCorrection(runId, plan, phase) { + if (!runId || plan?.completion_requirement_correction !== 'download_requires_state_change') return; + try { + await trace.recordNote(runId, 0, 'planner_completion_requirement_corrected', { + phase: phase === 'planner' ? 'planner' : 'intent', + requirement: 'download', + requiresStateChange: true, + }); + } catch {} + } + _plannerProgressLedgerGateFieldsFromApprovedPlanText(text) { const match = String(text || '').match( /^\s*-\s*Progress ledger:\s*(yes|no|auto)(?:\s*\(([^)\r\n]+)\))?\s*$/im, @@ -9960,6 +9979,10 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d return null; } + _plannerDownloadGateFieldFromApprovedPlanText(text) { + return /^\s*-\s*Download required:\s*yes\s*$/im.test(String(text || '')); + } + _plannerReadScopeFromApprovedPlanText(text) { const match = String(text || '').match( /^\s*-\s*Read scope:\s*(complete_thread|current_message|visible_page|none)\s*$/im, @@ -10320,6 +10343,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d ? this._plannerIntentRecheckFallback() : this._plannerActContinuation(runOptions, onUpdate, runId, 'inconsistent_intent'); } + await this._tracePlannerCompletionRequirementCorrection(runId, plan, 'intent'); if (plan.request_kind === 'respond') { return { proceed: true, @@ -10347,6 +10371,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d allowsPlannerShapedResult: plan.allows_planner_shaped_result === true, allowsAppStateToolEvidence: plan.allows_app_state_tool_evidence === true, requiredSchedulingTool: plan.scheduling?.tool || null, + ...this._plannerCompletionGateFields(plan), ...this._plannerProgressLedgerGateFields(plan), }; } catch (e) { @@ -10524,6 +10549,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d ? this._strictPlannerFailure(onUpdate) : this._plannerActContinuation(runOptions, onUpdate, runId, 'inconsistent_intent'); } + await this._tracePlannerCompletionRequirementCorrection(runId, plan, 'planner'); if (this._shouldRecheckReadOnlyFollowUpIntent(plan, historyDigest, followUpContext)) { const intentGate = await this._runPlannerIntentGate( tabId, @@ -10590,6 +10616,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d allowsPlannerShapedResult: plan.allows_planner_shaped_result === true, allowsAppStateToolEvidence: plan.allows_app_state_tool_evidence === true, requiredSchedulingTool: plan.scheduling?.tool || null, + ...this._plannerCompletionGateFields(plan), ...this._plannerProgressLedgerGateFields(plan), }; } @@ -10625,10 +10652,15 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d const approvedStepsChanged = verbosePlanEdited && this._approvedPlanStepsText(approvedText) !== this._approvedPlanStepsText(verboseMarkdown); const approvedReadScopeStepsChanged = !!editedText && ( - verbosePlanEdited + choice?.markdownMode === 'verbose' ? approvedStepsChanged : this._compactApprovedPlanStepsChanged(plan, approvedText) ); + const approvedDownloadMetadata = verbosePlanEdited + ? this._plannerDownloadGateFieldFromApprovedPlanText(approvedText) + : plan.completion_requirements?.download === true; + const approvedRequiresDownload = approvedDownloadMetadata === true + && !approvedReadScopeStepsChanged; // The visible metadata remains authoritative for unrelated edits, but a // changed Steps section can remove the action that justified the // planner's original positive submit intent while leaving its generated @@ -10644,6 +10676,10 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d const approvedReadScope = approvedReadScopeStepsChanged && approvedReadScopeMetadata === 'complete_thread' ? 'none' : approvedReadScopeMetadata; + const approvedRequiresStateChange = !approvedRequiresDownload + && plan.completion_requirement_correction === 'download_requires_state_change' + ? false + : plan.requires_state_change === true; const approvedScratchpadText = formatPlanScratchpad(plan, approvedText, canonicalVerboseMarkdown); this._armReadCompletenessFromPlan(tabId, { request_kind: 'execute', read_scope: approvedReadScope }); return { @@ -10652,11 +10688,12 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d planId, skillIds: approvedSkillIds, requestKind: 'execute', - requiresStateChange: plan.requires_state_change === true, + requiresStateChange: approvedRequiresStateChange, requiresSubmission: approvedRequiresSubmission, allowsPlannerShapedResult: plan.allows_planner_shaped_result === true, allowsAppStateToolEvidence: plan.allows_app_state_tool_evidence === true, requiredSchedulingTool: approvedSchedulingTool, + requiresDownload: approvedRequiresDownload, ...approvedProgressLedger, }; } catch (e) { @@ -14471,7 +14508,10 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d const enabled = this._isActionMode(mode) && runOptions?.cloudRun !== true && requestKind === 'execute'; - const requiresStateChange = typeof gateOutcome?.requiresStateChange === 'boolean' + const requiresDownload = gateOutcome?.requiresDownload === true; + const requiresStateChange = requiresDownload + ? true + : typeof gateOutcome?.requiresStateChange === 'boolean' ? gateOutcome.requiresStateChange : null; const requiresSubmission = typeof gateOutcome?.requiresSubmission === 'boolean' @@ -14490,6 +14530,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d && carried?.requestKind === 'execute' && carried.requiresStateChange === requiresStateChange && carried.requiresSubmission === requiresSubmission + && carried.requiresDownload === requiresDownload && carried.allowsAppStateToolEvidence === allowsAppStateToolEvidence && carried.requiredSchedulingTool === requiredSchedulingTool && carried.conversationId === (this.conversationIds.get(tabId) || null); @@ -14498,6 +14539,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d requestKind, requiresStateChange, requiresSubmission, + requiresDownload, allowsPlannerShapedResult: gateOutcome?.allowsPlannerShapedResult === true, allowsAppStateToolEvidence, requiredSchedulingTool, @@ -14506,6 +14548,10 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d // the immediately preceding run; ordinary user turns always start at 0. successfulTaskToolCalls: carryMatches ? carried.successfulTaskToolCalls : 0, successfulConsequentialToolCalls: carryMatches ? carried.successfulConsequentialToolCalls : 0, + successfulDownloadToolCalls: carryMatches ? (carried.successfulDownloadToolCalls || 0) : 0, + pendingDownloadIds: carryMatches && Array.isArray(carried.pendingDownloadIds) + ? [...carried.pendingDownloadIds] + : [], successfulRequiredSchedulingToolCalls: carryMatches ? (carried.successfulRequiredSchedulingToolCalls || 0) : 0, @@ -14574,7 +14620,69 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d return capabilities.some(capability => mutationCapabilities.has(capability)); } - _markPlanExecutionToolCall(tabId, name, result, { consequential = false } = {}) { + _isSuccessfulDownloadEvidence(name, result) { + if (!this._isSuccessfulExecutionEvidence(result)) return false; + if (name === 'screenshot' || name === 'full_page_screenshot') { + return result?.savedFile?.downloadId != null && result.savedFile.state === 'complete'; + } + if (name === 'download_files' || name === 'download_file') { + return Array.isArray(result?.downloads) + && result.downloads.some(item => ( + item?.success === true + && item.downloadId != null + && item.state === 'complete' + )); + } + if (name === 'download_resource_from_page') { + return result?.downloadId != null && result.state === 'complete'; + } + if (name === 'download_social_media') { + return Number(result?.completedCount || 0) > 0 + || (result?.savedFile?.downloadId != null && result.savedFile.state === 'complete'); + } + return result?.downloadId != null && result.state === 'complete'; + } + + _pendingDownloadIdsFromResult(name, result) { + if (!result || typeof result !== 'object' || result.denied || result.cancelled) return []; + if (name === 'download_files' || name === 'download_file') { + return (Array.isArray(result.downloads) ? result.downloads : []) + .filter(item => ( + item?.downloadId != null + && item.state !== 'complete' + && item.state !== 'interrupted' + && item.success !== false + )) + .map(item => item.downloadId); + } + if (result.downloadId != null + && result.state !== 'complete' + && result.state !== 'interrupted' + && (result.pending === true || result.success === true)) { + return [result.downloadId]; + } + return []; + } + + _confirmPendingDownloadEvidence(state, result) { + if (!state?.requiresDownload + || !Array.isArray(state.pendingDownloadIds) + || state.pendingDownloadIds.length === 0 + || !this._isSuccessfulExecutionEvidence(result) + || !Array.isArray(result?.downloads)) return false; + const completedIds = new Set( + result.downloads + .filter(item => item?.id != null && item.state === 'complete') + .map(item => String(item.id)), + ); + if (completedIds.size === 0) return false; + const remaining = state.pendingDownloadIds.filter(id => !completedIds.has(String(id))); + if (remaining.length === state.pendingDownloadIds.length) return false; + state.pendingDownloadIds = remaining; + return true; + } + + _markPlanExecutionToolCall(tabId, name, result, { consequential = false, download = false } = {}) { const state = this._planExecutionGuards.get(tabId); const requestedAppStateTool = state?.allowsAppStateToolEvidence === true && this.constructor.EXECUTION_APP_STATE_TOOLS.has(name); @@ -14586,6 +14694,12 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d // on the completion invariant's required follow-up page observation. const unverifiedFindText = name === 'find_text' && (result?.found !== true || result?.verified !== true || result?.inconclusive === true); + if (state?.enabled && download) { + const pendingIds = this._pendingDownloadIdsFromResult(name, result); + state.pendingDownloadIds = [...new Set([...state.pendingDownloadIds, ...pendingIds])]; + } + const confirmedPendingDownload = name === 'list_downloads' + && this._confirmPendingDownloadEvidence(state, result); if (!state?.enabled || name === 'done' || (this.constructor.EXECUTION_META_TOOLS.has(name) && !requestedAppStateTool) @@ -14593,7 +14707,11 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d || (!this._isSuccessfulExecutionEvidence(result) && !requiredScheduleSucceeded)) return; state.successfulTaskToolCalls += 1; if (requiredScheduleSucceeded) state.successfulRequiredSchedulingToolCalls += 1; + if ((download && this._isSuccessfulDownloadEvidence(name, result)) || confirmedPendingDownload) { + state.successfulDownloadToolCalls += 1; + } if (consequential + || confirmedPendingDownload || requiredScheduleSucceeded || (requestedAppStateTool && this.constructor.EXECUTION_APP_STATE_WRITE_TOOLS.has(name))) { state.successfulConsequentialToolCalls += 1; @@ -14610,21 +14728,30 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d : state.successfulConsequentialToolCalls > 0; const schedulingEvidenceSatisfied = !state.requiredSchedulingTool || state.successfulRequiredSchedulingToolCalls > 0; - return taskEvidenceSatisfied && schedulingEvidenceSatisfied; + const downloadEvidenceSatisfied = !state.requiresDownload + || state.successfulDownloadToolCalls > 0; + return taskEvidenceSatisfied && schedulingEvidenceSatisfied && downloadEvidenceSatisfied; } _storeContinuationExecutionEvidence(tabId) { const guard = this._planExecutionGuards.get(tabId); - if (guard?.enabled && (guard.successfulTaskToolCalls > 0 || guard.successfulConsequentialToolCalls > 0)) { + if (guard?.enabled && ( + guard.successfulTaskToolCalls > 0 + || guard.successfulConsequentialToolCalls > 0 + || guard.pendingDownloadIds.length > 0 + )) { const submit = this._completionSubmitStates.get(tabId); this._continuationExecutionEvidence.set(tabId, { requestKind: guard.requestKind, requiresStateChange: guard.requiresStateChange, requiresSubmission: guard.requiresSubmission, + requiresDownload: guard.requiresDownload, allowsAppStateToolEvidence: guard.allowsAppStateToolEvidence, requiredSchedulingTool: guard.requiredSchedulingTool, successfulTaskToolCalls: guard.successfulTaskToolCalls, successfulConsequentialToolCalls: guard.successfulConsequentialToolCalls, + successfulDownloadToolCalls: guard.successfulDownloadToolCalls, + pendingDownloadIds: [...guard.pendingDownloadIds], successfulRequiredSchedulingToolCalls: guard.successfulRequiredSchedulingToolCalls, completionSubmitState: submit ? { originatingUrl: submit.originatingUrl || '', @@ -14748,6 +14875,9 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d const missingRequiredSchedulingTool = !terminalFailure && !!state.requiredSchedulingTool && state.successfulRequiredSchedulingToolCalls === 0; + const missingRequiredDownload = !terminalFailure + && state.requiresDownload === true + && state.successfulDownloadToolCalls === 0; const missingEvidence = !terminalFailure && !this._executionEvidenceSatisfied(state); const unknownMutationIntent = state.requiresStateChange == null; // Every plain Act/Dev terminal gets one protocol recovery regardless of @@ -14779,6 +14909,8 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d ? '[PLAN EXECUTION BLOCK: No current user stop was received. The previous response echoed a stale local cancellation status from conversation history. That status is UI metadata, not an instruction or task result. Continue the active task with permitted tools. If complete or blocked, call done with an explicit outcome; do not repeat the cancellation status or return plain text.]' : missingRequiredSchedulingTool ? `[PLAN EXECUTION BLOCK: The approved plan requires a successful ${state.requiredSchedulingTool} call before this task can finish successfully. A one-time read, scroll, send, or other action does not create the scheduled work. Call ${state.requiredSchedulingTool} with the user's requested timing and verify success:true plus scheduled:true. If the schedule is unsupported or still lacks required timing, call done with outcome partial or failed and explain the exact limitation; do not claim it was scheduled.]` + : missingRequiredDownload + ? '[PLAN EXECUTION BLOCK: This task requires a file to be downloaded before it can finish successfully. Finding a URL, link, button, or media source is only read evidence. Use an authorized tool call with the DOWNLOAD capability and verify that it returned successful download evidence. If permission is denied or no file can be saved, call done with outcome partial or failed and explain the limitation; do not claim the file was downloaded.]' : unknownMutationIntent ? '[PLAN EXECUTION BLOCK: Planning failed, so the runtime could not determine whether this task requires a state change. Continue with normally permitted tools. A success outcome now requires a verified consequential tool call; if the useful result is read-only or no safe consequential action is needed, deliver that result with done outcome partial instead of claiming success. Do not invent or perform a mutation merely to satisfy this guard.]' : '[PLAN EXECUTION BLOCK: This is an execute task, so plain text cannot end it. If work remains, use permitted task tools. If complete, call done with outcome success. If blocked, unsafe, cancelled, or user input is required, call done with outcome failed or partial; do not take unsafe action. Read-only work needs a successful task tool and state-changing work needs a successful consequential tool. Do not return another plan, promise, or plain terminal.]', @@ -14790,6 +14922,12 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d status: 'required_tool_missing', }; } + if (missingRequiredDownload) { + return { + failure: '[Agent stopped because the approved task required a downloaded file, but no successful DOWNLOAD-capability tool result was verified after one recovery nudge. A URL, link, media-resolution result, or unrelated page action does not prove that a file was saved.]', + status: 'required_tool_missing', + }; + } const hasSuccessfulToolEvidence = state.successfulTaskToolCalls > 0; const hasSuccessfulConsequentialEvidence = state.successfulConsequentialToolCalls > 0; if (staleCancellation && state.staleCancellationRecoveryAttempted) { diff --git a/src/chrome/src/agent/planner.js b/src/chrome/src/agent/planner.js index 873c5879c..c8497df16 100644 --- a/src/chrome/src/agent/planner.js +++ b/src/chrome/src/agent/planner.js @@ -51,6 +51,12 @@ const PLANNER_LOCALIZED_SCHEMA = { }, required: ['locale', 'summary', 'steps', 'risks'], }; +const PLANNER_COMPLETION_REQUIREMENTS_SCHEMA = { + type: 'object', + additionalProperties: false, + properties: { download: { type: 'boolean' } }, + required: ['download'], +}; export const PLANNER_RESPONSE_JSON_SCHEMA = { type: 'object', @@ -59,6 +65,7 @@ export const PLANNER_RESPONSE_JSON_SCHEMA = { request_kind: PLANNER_REQUEST_KIND_SCHEMA, requires_state_change: { type: 'boolean' }, requires_submission: { type: 'boolean' }, + completion_requirements: PLANNER_COMPLETION_REQUIREMENTS_SCHEMA, allows_planner_shaped_result: { type: 'boolean' }, allows_app_state_tool_evidence: { type: 'boolean' }, read_scope: PLANNER_READ_SCOPE_SCHEMA, @@ -98,6 +105,7 @@ export const PLANNER_RESPONSE_JSON_SCHEMA = { 'request_kind', 'requires_state_change', 'requires_submission', + 'completion_requirements', 'allows_planner_shaped_result', 'allows_app_state_tool_evidence', 'read_scope', @@ -120,6 +128,7 @@ export const PLANNER_INTENT_RESPONSE_JSON_SCHEMA = { request_kind: PLANNER_REQUEST_KIND_SCHEMA, requires_state_change: { type: 'boolean' }, requires_submission: { type: 'boolean' }, + completion_requirements: PLANNER_COMPLETION_REQUIREMENTS_SCHEMA, allows_planner_shaped_result: { type: 'boolean' }, allows_app_state_tool_evidence: { type: 'boolean' }, read_scope: PLANNER_READ_SCOPE_SCHEMA, @@ -150,6 +159,7 @@ export const PLANNER_INTENT_RESPONSE_JSON_SCHEMA = { 'request_kind', 'requires_state_change', 'requires_submission', + 'completion_requirements', 'allows_planner_shaped_result', 'allows_app_state_tool_evidence', 'read_scope', @@ -169,14 +179,6 @@ export const READ_SCOPE_RESPONSE_JSON_SCHEMA = { required: ['read_scope'], }; -function canonicalPlanRequiresDownload(_summary, _steps) { - // TODO(#2752): Derive download completion requirements from structured, - // language-neutral planner intent. Do not infer them from canonical prose; - // lookup framing such as "Find the URL to download the report" makes that - // heuristic ambiguous. Until then, preserve the planner-declared value. - return false; -} - export const PLANNER_API_REPLAY_RULE = '- Because API mutations are authorized, repeated same-kind UI mutations may include a conditional API branch: if WebBrain later reports a [BULK API MUTATION PATTERN], sample exactly one fetch_url replay with the provided replayRequestId. If that sample fails with success:false or HTTP 4xx/5xx, stop using API for that request shape and continue through the paced visible-UI loop.'; // Keep response-only routing identical across the full Plan-before-Act planner @@ -195,6 +197,7 @@ Schema: "request_kind": "execute" | "respond" | "plan_only" | "clarify", "requires_state_change": boolean, "requires_submission": boolean, + "completion_requirements": { "download": boolean }, "allows_planner_shaped_result": boolean, "allows_app_state_tool_evidence": boolean, "read_scope": "complete_thread" | "current_message" | "visible_page" | "none", @@ -239,6 +242,7 @@ ${PLANNER_RESPONSE_ONLY_RULES} - Classify clarify immediately only when trusted current-task context already proves a required value is missing and no useful inspection or action can happen first. Otherwise classify execute and include a conditional clarify step after inspection. - requires_state_change is true only when completing an execute request needs a mutation such as interacting with form/account state, modifying page data, downloading/uploading a file, a write-method network request, a Dev patch, or scheduling work. It is false for reads, analysis, summaries, navigation, scrolling, hovering, window/viewport changes, plan_only, and clarify. - requires_submission is true when the user-authorized task ultimately requires an explicit form/dialog commit action such as Submit, Save, Send, Publish, Post, or Confirm. For clarify, preserve true when the missing answer is only a prerequisite to that already-requested commit; clarify itself still performs no action. It is false for filling, editing, checking, or selecting without committing, including explicit do-not-submit tasks and autosave UIs, and false for respond and plan_only. +- completion_requirements.download is true only when success requires WebBrain to write a file into browser/OS download storage. It is false when the user asks only to find a download URL, link, button, instructions, or an explanation, even if that result refers to a future download. Classify this semantic intent across any language, not with word matching. This field only tightens completion evidence; it never authorizes tools, changes mode, or bypasses download permission. - Do not classify a follow-up as clarify merely because it refers to answers, drafts, or values already prepared in the ongoing task or currently present on the page. When the user authorizes using those existing values, classify execute and inspect them with read tools; clarify only after the available trusted context or runtime inspection cannot supply a required value. - 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. - allows_app_state_tool_evidence is true only when the requested work itself is reading/updating WebBrain scratchpad or progress ledger (not incidental bookkeeping). @@ -272,6 +276,7 @@ export const PLANNER_INTENT_SYSTEM_PROMPT = `You are the intent and compact plan "request_kind": "execute" | "respond" | "plan_only" | "clarify", "requires_state_change": boolean, "requires_submission": boolean, + "completion_requirements": { "download": boolean }, "allows_planner_shaped_result": boolean, "allows_app_state_tool_evidence": boolean, "read_scope": "complete_thread" | "current_message" | "visible_page" | "none", @@ -308,6 +313,7 @@ ${PLANNER_RESPONSE_ONLY_RULES} - Classify clarify immediately only when trusted current-task context already proves a required value is missing and no useful inspection or action can happen first. Otherwise classify execute and make the need to clarify after inspection explicit in the step action. - 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 when the user-authorized task ultimately requires an explicit form/dialog commit action such as Submit, Save, Send, Publish, Post, or Confirm. For clarify, preserve true when the missing answer is only a prerequisite to that already-requested commit; clarify itself still performs no action. It is false for filling, editing, checking, or selecting without committing, including explicit do-not-submit tasks and autosave UIs, and false for respond and plan_only. +- completion_requirements.download is true only when success requires WebBrain to write a file into browser/OS download storage. It is false for finding a download URL, link, button, instructions, or explanation, even when that result mentions a future download. Decide semantically across any language, never by matching words. This metadata only tightens completion evidence; it does not authorize tools, change mode, or bypass download permission. - Do not classify a follow-up as clarify merely because it refers to answers, drafts, or values already prepared in the ongoing task or currently present on the page. When the user authorizes using those existing values, classify execute and inspect them with read tools; clarify only after the available trusted context or runtime inspection cannot supply a required value. - 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. - allows_app_state_tool_evidence is true only when the requested work itself is reading/updating WebBrain scratchpad or progress ledger (not incidental bookkeeping). @@ -556,18 +562,27 @@ export function normalizePlan(obj, opts = {}) { const requiresSubmission = submissionBearingPlan ? (hasRequiresSubmission ? obj.requires_submission === true : null) : false; + const requiresDownload = executablePlan + && obj.completion_requirements?.download === true; + const completionRequirementCorrection = requiresDownload + && hasRequiresStateChange + && obj.requires_state_change === false + ? 'download_requires_state_change' + : null; const requiresStateChange = executablePlan ? ( !!obj.requires_state_change || requiresSubmission === true || !!normalizedScheduling - || canonicalPlanRequiresDownload(summary, steps) + || requiresDownload ) : false; return { request_kind: requestKind, requires_state_change: requiresStateChange, requires_submission: requiresSubmission, + completion_requirements: { download: requiresDownload }, + completion_requirement_correction: completionRequirementCorrection, allows_planner_shaped_result: requestKind === 'execute' && obj.allows_planner_shaped_result === true, allows_app_state_tool_evidence: requestKind === 'execute' && obj.allows_app_state_tool_evidence === true, read_scope: requestKind === 'execute' || (!opts.requireIntent && requestKind === null) @@ -633,6 +648,7 @@ function formatPlanConfidence(plan) { function appendPlanExecutionMetadata(lines, plan) { lines.push('### Completion requirements'); lines.push(`- Submission required: ${plan.requires_submission === true ? 'yes' : (plan.requires_submission === false ? 'no' : 'auto')}`); + lines.push(`- Download required: ${plan.completion_requirements?.download === true ? 'yes' : 'no'}`); lines.push(`- Read scope: ${normalizeReadScope(plan.read_scope) || 'none'}`); lines.push(''); diff --git a/src/chrome/src/network/network-tools.js b/src/chrome/src/network/network-tools.js index b0974ae1d..0619e38b8 100644 --- a/src/chrome/src/network/network-tools.js +++ b/src/chrome/src/network/network-tools.js @@ -2309,12 +2309,24 @@ export async function downloadResourceFromPage(tabId, args = {}) { else resolve(id); }); }); + const info = await resolveDownloadInfo(downloadId); + const complete = info?.state === 'complete'; return { - success: true, + success: complete, downloadId, sourceUrl: r.isBlob ? '[blob]' : r.url, mime: r.mime || null, blob: !!r.isBlob, + ...(info?.filename ? { filename: info.filename } : {}), + ...(info?.state ? { state: info.state } : {}), + ...(info?.bytesReceived != null ? { bytesReceived: info.bytesReceived } : {}), + ...(info?.totalBytes != null ? { totalBytes: info.totalBytes } : {}), + ...(!complete ? { + pending: info?.state !== 'interrupted', + error: info?.state === 'interrupted' + ? `Download interrupted${info.error ? `: ${info.error}` : ' before completion.'}` + : `Download did not complete before timeout${info?.state ? ` (state: ${info.state})` : ''}.`, + } : {}), }; } catch (e) { return { success: false, error: e.message }; diff --git a/src/firefox/src/agent/agent.js b/src/firefox/src/agent/agent.js index 010af8fa3..371b79734 100644 --- a/src/firefox/src/agent/agent.js +++ b/src/firefox/src/agent/agent.js @@ -5111,6 +5111,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d if (fnName !== 'done') { this._markPlanExecutionToolCall(tabId, fnName, toolResult, { consequential: executionMutationEvidence, + download: capabilities.includes(Capability.DOWNLOAD), }); } const completionStateBeforeTool = this.completionInvariants.get(tabId) || null; @@ -8106,6 +8107,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d proceed: true, requestKind: 'execute', requiresStateChange: true, + requiresDownload: fastPathPlan.id === 'download-media', progressLedgerPolicy: 'auto', progressAction: null, }; @@ -8542,6 +8544,23 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d }; } + _plannerCompletionGateFields(plan) { + return { + requiresDownload: plan?.completion_requirements?.download === true, + }; + } + + async _tracePlannerCompletionRequirementCorrection(runId, plan, phase) { + if (!runId || plan?.completion_requirement_correction !== 'download_requires_state_change') return; + try { + await trace.recordNote(runId, 0, 'planner_completion_requirement_corrected', { + phase: phase === 'planner' ? 'planner' : 'intent', + requirement: 'download', + requiresStateChange: true, + }); + } catch {} + } + _plannerProgressLedgerGateFieldsFromApprovedPlanText(text) { const match = String(text || '').match( /^\s*-\s*Progress ledger:\s*(yes|no|auto)(?:\s*\(([^)\r\n]+)\))?\s*$/im, @@ -8568,6 +8587,10 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d return null; } + _plannerDownloadGateFieldFromApprovedPlanText(text) { + return /^\s*-\s*Download required:\s*yes\s*$/im.test(String(text || '')); + } + _plannerReadScopeFromApprovedPlanText(text) { const match = String(text || '').match( /^\s*-\s*Read scope:\s*(complete_thread|current_message|visible_page|none)\s*$/im, @@ -8928,6 +8951,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d ? this._plannerIntentRecheckFallback() : this._plannerActContinuation(runOptions, onUpdate, runId, 'inconsistent_intent'); } + await this._tracePlannerCompletionRequirementCorrection(runId, plan, 'intent'); if (plan.request_kind === 'respond') { return { proceed: true, @@ -8955,6 +8979,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d allowsPlannerShapedResult: plan.allows_planner_shaped_result === true, allowsAppStateToolEvidence: plan.allows_app_state_tool_evidence === true, requiredSchedulingTool: plan.scheduling?.tool || null, + ...this._plannerCompletionGateFields(plan), ...this._plannerProgressLedgerGateFields(plan), }; } catch (e) { @@ -9128,6 +9153,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d ? this._strictPlannerFailure(onUpdate) : this._plannerActContinuation(runOptions, onUpdate, runId, 'inconsistent_intent'); } + await this._tracePlannerCompletionRequirementCorrection(runId, plan, 'planner'); if (this._shouldRecheckReadOnlyFollowUpIntent(plan, historyDigest, followUpContext)) { const intentGate = await this._runPlannerIntentGate( tabId, @@ -9194,6 +9220,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d allowsPlannerShapedResult: plan.allows_planner_shaped_result === true, allowsAppStateToolEvidence: plan.allows_app_state_tool_evidence === true, requiredSchedulingTool: plan.scheduling?.tool || null, + ...this._plannerCompletionGateFields(plan), ...this._plannerProgressLedgerGateFields(plan), }; } @@ -9229,10 +9256,15 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d const approvedStepsChanged = verbosePlanEdited && this._approvedPlanStepsText(approvedText) !== this._approvedPlanStepsText(verboseMarkdown); const approvedReadScopeStepsChanged = !!editedText && ( - verbosePlanEdited + choice?.markdownMode === 'verbose' ? approvedStepsChanged : this._compactApprovedPlanStepsChanged(plan, approvedText) ); + const approvedDownloadMetadata = verbosePlanEdited + ? this._plannerDownloadGateFieldFromApprovedPlanText(approvedText) + : plan.completion_requirements?.download === true; + const approvedRequiresDownload = approvedDownloadMetadata === true + && !approvedReadScopeStepsChanged; // The visible metadata remains authoritative for unrelated edits, but a // changed Steps section can remove the action that justified the // planner's original positive submit intent while leaving its generated @@ -9248,6 +9280,10 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d const approvedReadScope = approvedReadScopeStepsChanged && approvedReadScopeMetadata === 'complete_thread' ? 'none' : approvedReadScopeMetadata; + const approvedRequiresStateChange = !approvedRequiresDownload + && plan.completion_requirement_correction === 'download_requires_state_change' + ? false + : plan.requires_state_change === true; const approvedScratchpadText = formatPlanScratchpad(plan, approvedText, canonicalVerboseMarkdown); this._armReadCompletenessFromPlan(tabId, { request_kind: 'execute', read_scope: approvedReadScope }); return { @@ -9256,11 +9292,12 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d planId, skillIds: approvedSkillIds, requestKind: 'execute', - requiresStateChange: plan.requires_state_change === true, + requiresStateChange: approvedRequiresStateChange, requiresSubmission: approvedRequiresSubmission, allowsPlannerShapedResult: plan.allows_planner_shaped_result === true, allowsAppStateToolEvidence: plan.allows_app_state_tool_evidence === true, requiredSchedulingTool: approvedSchedulingTool, + requiresDownload: approvedRequiresDownload, ...approvedProgressLedger, }; } catch (e) { @@ -12860,7 +12897,10 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d const enabled = this._isActionMode(mode) && runOptions?.cloudRun !== true && requestKind === 'execute'; - const requiresStateChange = typeof gateOutcome?.requiresStateChange === 'boolean' + const requiresDownload = gateOutcome?.requiresDownload === true; + const requiresStateChange = requiresDownload + ? true + : typeof gateOutcome?.requiresStateChange === 'boolean' ? gateOutcome.requiresStateChange : null; const requiresSubmission = typeof gateOutcome?.requiresSubmission === 'boolean' @@ -12879,6 +12919,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d && carried?.requestKind === 'execute' && carried.requiresStateChange === requiresStateChange && carried.requiresSubmission === requiresSubmission + && carried.requiresDownload === requiresDownload && carried.allowsAppStateToolEvidence === allowsAppStateToolEvidence && carried.requiredSchedulingTool === requiredSchedulingTool && carried.conversationId === (this.conversationIds.get(tabId) || null); @@ -12887,6 +12928,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d requestKind, requiresStateChange, requiresSubmission, + requiresDownload, allowsPlannerShapedResult: gateOutcome?.allowsPlannerShapedResult === true, allowsAppStateToolEvidence, requiredSchedulingTool, @@ -12895,6 +12937,10 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d // the immediately preceding run; ordinary user turns always start at 0. successfulTaskToolCalls: carryMatches ? carried.successfulTaskToolCalls : 0, successfulConsequentialToolCalls: carryMatches ? carried.successfulConsequentialToolCalls : 0, + successfulDownloadToolCalls: carryMatches ? (carried.successfulDownloadToolCalls || 0) : 0, + pendingDownloadIds: carryMatches && Array.isArray(carried.pendingDownloadIds) + ? [...carried.pendingDownloadIds] + : [], successfulRequiredSchedulingToolCalls: carryMatches ? (carried.successfulRequiredSchedulingToolCalls || 0) : 0, @@ -12963,7 +13009,69 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d return capabilities.some(capability => mutationCapabilities.has(capability)); } - _markPlanExecutionToolCall(tabId, name, result, { consequential = false } = {}) { + _isSuccessfulDownloadEvidence(name, result) { + if (!this._isSuccessfulExecutionEvidence(result)) return false; + if (name === 'screenshot' || name === 'full_page_screenshot') { + return result?.savedFile?.downloadId != null && result.savedFile.state === 'complete'; + } + if (name === 'download_files' || name === 'download_file') { + return Array.isArray(result?.downloads) + && result.downloads.some(item => ( + item?.success === true + && item.downloadId != null + && item.state === 'complete' + )); + } + if (name === 'download_resource_from_page') { + return result?.downloadId != null && result.state === 'complete'; + } + if (name === 'download_social_media') { + return Number(result?.completedCount || 0) > 0 + || (result?.savedFile?.downloadId != null && result.savedFile.state === 'complete'); + } + return result?.downloadId != null && result.state === 'complete'; + } + + _pendingDownloadIdsFromResult(name, result) { + if (!result || typeof result !== 'object' || result.denied || result.cancelled) return []; + if (name === 'download_files' || name === 'download_file') { + return (Array.isArray(result.downloads) ? result.downloads : []) + .filter(item => ( + item?.downloadId != null + && item.state !== 'complete' + && item.state !== 'interrupted' + && item.success !== false + )) + .map(item => item.downloadId); + } + if (result.downloadId != null + && result.state !== 'complete' + && result.state !== 'interrupted' + && (result.pending === true || result.success === true)) { + return [result.downloadId]; + } + return []; + } + + _confirmPendingDownloadEvidence(state, result) { + if (!state?.requiresDownload + || !Array.isArray(state.pendingDownloadIds) + || state.pendingDownloadIds.length === 0 + || !this._isSuccessfulExecutionEvidence(result) + || !Array.isArray(result?.downloads)) return false; + const completedIds = new Set( + result.downloads + .filter(item => item?.id != null && item.state === 'complete') + .map(item => String(item.id)), + ); + if (completedIds.size === 0) return false; + const remaining = state.pendingDownloadIds.filter(id => !completedIds.has(String(id))); + if (remaining.length === state.pendingDownloadIds.length) return false; + state.pendingDownloadIds = remaining; + return true; + } + + _markPlanExecutionToolCall(tabId, name, result, { consequential = false, download = false } = {}) { const state = this._planExecutionGuards.get(tabId); const requestedAppStateTool = state?.allowsAppStateToolEvidence === true && this.constructor.EXECUTION_APP_STATE_TOOLS.has(name); @@ -12975,6 +13083,12 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d // on the completion invariant's required follow-up page observation. const unverifiedFindText = name === 'find_text' && (result?.found !== true || result?.verified !== true || result?.inconclusive === true); + if (state?.enabled && download) { + const pendingIds = this._pendingDownloadIdsFromResult(name, result); + state.pendingDownloadIds = [...new Set([...state.pendingDownloadIds, ...pendingIds])]; + } + const confirmedPendingDownload = name === 'list_downloads' + && this._confirmPendingDownloadEvidence(state, result); if (!state?.enabled || name === 'done' || (this.constructor.EXECUTION_META_TOOLS.has(name) && !requestedAppStateTool) @@ -12982,7 +13096,11 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d || (!this._isSuccessfulExecutionEvidence(result) && !requiredScheduleSucceeded)) return; state.successfulTaskToolCalls += 1; if (requiredScheduleSucceeded) state.successfulRequiredSchedulingToolCalls += 1; + if ((download && this._isSuccessfulDownloadEvidence(name, result)) || confirmedPendingDownload) { + state.successfulDownloadToolCalls += 1; + } if (consequential + || confirmedPendingDownload || requiredScheduleSucceeded || (requestedAppStateTool && this.constructor.EXECUTION_APP_STATE_WRITE_TOOLS.has(name))) { state.successfulConsequentialToolCalls += 1; @@ -12999,21 +13117,30 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d : state.successfulConsequentialToolCalls > 0; const schedulingEvidenceSatisfied = !state.requiredSchedulingTool || state.successfulRequiredSchedulingToolCalls > 0; - return taskEvidenceSatisfied && schedulingEvidenceSatisfied; + const downloadEvidenceSatisfied = !state.requiresDownload + || state.successfulDownloadToolCalls > 0; + return taskEvidenceSatisfied && schedulingEvidenceSatisfied && downloadEvidenceSatisfied; } _storeContinuationExecutionEvidence(tabId) { const guard = this._planExecutionGuards.get(tabId); - if (guard?.enabled && (guard.successfulTaskToolCalls > 0 || guard.successfulConsequentialToolCalls > 0)) { + if (guard?.enabled && ( + guard.successfulTaskToolCalls > 0 + || guard.successfulConsequentialToolCalls > 0 + || guard.pendingDownloadIds.length > 0 + )) { const submit = this._completionSubmitStates.get(tabId); this._continuationExecutionEvidence.set(tabId, { requestKind: guard.requestKind, requiresStateChange: guard.requiresStateChange, requiresSubmission: guard.requiresSubmission, + requiresDownload: guard.requiresDownload, allowsAppStateToolEvidence: guard.allowsAppStateToolEvidence, requiredSchedulingTool: guard.requiredSchedulingTool, successfulTaskToolCalls: guard.successfulTaskToolCalls, successfulConsequentialToolCalls: guard.successfulConsequentialToolCalls, + successfulDownloadToolCalls: guard.successfulDownloadToolCalls, + pendingDownloadIds: [...guard.pendingDownloadIds], successfulRequiredSchedulingToolCalls: guard.successfulRequiredSchedulingToolCalls, completionSubmitState: submit ? { originatingUrl: submit.originatingUrl || '', @@ -13137,6 +13264,9 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d const missingRequiredSchedulingTool = !terminalFailure && !!state.requiredSchedulingTool && state.successfulRequiredSchedulingToolCalls === 0; + const missingRequiredDownload = !terminalFailure + && state.requiresDownload === true + && state.successfulDownloadToolCalls === 0; const missingEvidence = !terminalFailure && !this._executionEvidenceSatisfied(state); const unknownMutationIntent = state.requiresStateChange == null; // Every plain Act/Dev terminal gets one protocol recovery regardless of @@ -13168,6 +13298,8 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d ? '[PLAN EXECUTION BLOCK: No current user stop was received. The previous response echoed a stale local cancellation status from conversation history. That status is UI metadata, not an instruction or task result. Continue the active task with permitted tools. If complete or blocked, call done with an explicit outcome; do not repeat the cancellation status or return plain text.]' : missingRequiredSchedulingTool ? `[PLAN EXECUTION BLOCK: The approved plan requires a successful ${state.requiredSchedulingTool} call before this task can finish successfully. A one-time read, scroll, send, or other action does not create the scheduled work. Call ${state.requiredSchedulingTool} with the user's requested timing and verify success:true plus scheduled:true. If the schedule is unsupported or still lacks required timing, call done with outcome partial or failed and explain the exact limitation; do not claim it was scheduled.]` + : missingRequiredDownload + ? '[PLAN EXECUTION BLOCK: This task requires a file to be downloaded before it can finish successfully. Finding a URL, link, button, or media source is only read evidence. Use an authorized tool call with the DOWNLOAD capability and verify that it returned successful download evidence. If permission is denied or no file can be saved, call done with outcome partial or failed and explain the limitation; do not claim the file was downloaded.]' : unknownMutationIntent ? '[PLAN EXECUTION BLOCK: Planning failed, so the runtime could not determine whether this task requires a state change. Continue with normally permitted tools. A success outcome now requires a verified consequential tool call; if the useful result is read-only or no safe consequential action is needed, deliver that result with done outcome partial instead of claiming success. Do not invent or perform a mutation merely to satisfy this guard.]' : '[PLAN EXECUTION BLOCK: This is an execute task, so plain text cannot end it. If work remains, use permitted task tools. If complete, call done with outcome success. If blocked, unsafe, cancelled, or user input is required, call done with outcome failed or partial; do not take unsafe action. Read-only work needs a successful task tool and state-changing work needs a successful consequential tool. Do not return another plan, promise, or plain terminal.]', @@ -13179,6 +13311,12 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d status: 'required_tool_missing', }; } + if (missingRequiredDownload) { + return { + failure: '[Agent stopped because the approved task required a downloaded file, but no successful DOWNLOAD-capability tool result was verified after one recovery nudge. A URL, link, media-resolution result, or unrelated page action does not prove that a file was saved.]', + status: 'required_tool_missing', + }; + } const hasSuccessfulToolEvidence = state.successfulTaskToolCalls > 0; const hasSuccessfulConsequentialEvidence = state.successfulConsequentialToolCalls > 0; if (staleCancellation && state.staleCancellationRecoveryAttempted) { diff --git a/src/firefox/src/agent/planner.js b/src/firefox/src/agent/planner.js index 873c5879c..c8497df16 100644 --- a/src/firefox/src/agent/planner.js +++ b/src/firefox/src/agent/planner.js @@ -51,6 +51,12 @@ const PLANNER_LOCALIZED_SCHEMA = { }, required: ['locale', 'summary', 'steps', 'risks'], }; +const PLANNER_COMPLETION_REQUIREMENTS_SCHEMA = { + type: 'object', + additionalProperties: false, + properties: { download: { type: 'boolean' } }, + required: ['download'], +}; export const PLANNER_RESPONSE_JSON_SCHEMA = { type: 'object', @@ -59,6 +65,7 @@ export const PLANNER_RESPONSE_JSON_SCHEMA = { request_kind: PLANNER_REQUEST_KIND_SCHEMA, requires_state_change: { type: 'boolean' }, requires_submission: { type: 'boolean' }, + completion_requirements: PLANNER_COMPLETION_REQUIREMENTS_SCHEMA, allows_planner_shaped_result: { type: 'boolean' }, allows_app_state_tool_evidence: { type: 'boolean' }, read_scope: PLANNER_READ_SCOPE_SCHEMA, @@ -98,6 +105,7 @@ export const PLANNER_RESPONSE_JSON_SCHEMA = { 'request_kind', 'requires_state_change', 'requires_submission', + 'completion_requirements', 'allows_planner_shaped_result', 'allows_app_state_tool_evidence', 'read_scope', @@ -120,6 +128,7 @@ export const PLANNER_INTENT_RESPONSE_JSON_SCHEMA = { request_kind: PLANNER_REQUEST_KIND_SCHEMA, requires_state_change: { type: 'boolean' }, requires_submission: { type: 'boolean' }, + completion_requirements: PLANNER_COMPLETION_REQUIREMENTS_SCHEMA, allows_planner_shaped_result: { type: 'boolean' }, allows_app_state_tool_evidence: { type: 'boolean' }, read_scope: PLANNER_READ_SCOPE_SCHEMA, @@ -150,6 +159,7 @@ export const PLANNER_INTENT_RESPONSE_JSON_SCHEMA = { 'request_kind', 'requires_state_change', 'requires_submission', + 'completion_requirements', 'allows_planner_shaped_result', 'allows_app_state_tool_evidence', 'read_scope', @@ -169,14 +179,6 @@ export const READ_SCOPE_RESPONSE_JSON_SCHEMA = { required: ['read_scope'], }; -function canonicalPlanRequiresDownload(_summary, _steps) { - // TODO(#2752): Derive download completion requirements from structured, - // language-neutral planner intent. Do not infer them from canonical prose; - // lookup framing such as "Find the URL to download the report" makes that - // heuristic ambiguous. Until then, preserve the planner-declared value. - return false; -} - export const PLANNER_API_REPLAY_RULE = '- Because API mutations are authorized, repeated same-kind UI mutations may include a conditional API branch: if WebBrain later reports a [BULK API MUTATION PATTERN], sample exactly one fetch_url replay with the provided replayRequestId. If that sample fails with success:false or HTTP 4xx/5xx, stop using API for that request shape and continue through the paced visible-UI loop.'; // Keep response-only routing identical across the full Plan-before-Act planner @@ -195,6 +197,7 @@ Schema: "request_kind": "execute" | "respond" | "plan_only" | "clarify", "requires_state_change": boolean, "requires_submission": boolean, + "completion_requirements": { "download": boolean }, "allows_planner_shaped_result": boolean, "allows_app_state_tool_evidence": boolean, "read_scope": "complete_thread" | "current_message" | "visible_page" | "none", @@ -239,6 +242,7 @@ ${PLANNER_RESPONSE_ONLY_RULES} - Classify clarify immediately only when trusted current-task context already proves a required value is missing and no useful inspection or action can happen first. Otherwise classify execute and include a conditional clarify step after inspection. - requires_state_change is true only when completing an execute request needs a mutation such as interacting with form/account state, modifying page data, downloading/uploading a file, a write-method network request, a Dev patch, or scheduling work. It is false for reads, analysis, summaries, navigation, scrolling, hovering, window/viewport changes, plan_only, and clarify. - requires_submission is true when the user-authorized task ultimately requires an explicit form/dialog commit action such as Submit, Save, Send, Publish, Post, or Confirm. For clarify, preserve true when the missing answer is only a prerequisite to that already-requested commit; clarify itself still performs no action. It is false for filling, editing, checking, or selecting without committing, including explicit do-not-submit tasks and autosave UIs, and false for respond and plan_only. +- completion_requirements.download is true only when success requires WebBrain to write a file into browser/OS download storage. It is false when the user asks only to find a download URL, link, button, instructions, or an explanation, even if that result refers to a future download. Classify this semantic intent across any language, not with word matching. This field only tightens completion evidence; it never authorizes tools, changes mode, or bypasses download permission. - Do not classify a follow-up as clarify merely because it refers to answers, drafts, or values already prepared in the ongoing task or currently present on the page. When the user authorizes using those existing values, classify execute and inspect them with read tools; clarify only after the available trusted context or runtime inspection cannot supply a required value. - 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. - allows_app_state_tool_evidence is true only when the requested work itself is reading/updating WebBrain scratchpad or progress ledger (not incidental bookkeeping). @@ -272,6 +276,7 @@ export const PLANNER_INTENT_SYSTEM_PROMPT = `You are the intent and compact plan "request_kind": "execute" | "respond" | "plan_only" | "clarify", "requires_state_change": boolean, "requires_submission": boolean, + "completion_requirements": { "download": boolean }, "allows_planner_shaped_result": boolean, "allows_app_state_tool_evidence": boolean, "read_scope": "complete_thread" | "current_message" | "visible_page" | "none", @@ -308,6 +313,7 @@ ${PLANNER_RESPONSE_ONLY_RULES} - Classify clarify immediately only when trusted current-task context already proves a required value is missing and no useful inspection or action can happen first. Otherwise classify execute and make the need to clarify after inspection explicit in the step action. - 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 when the user-authorized task ultimately requires an explicit form/dialog commit action such as Submit, Save, Send, Publish, Post, or Confirm. For clarify, preserve true when the missing answer is only a prerequisite to that already-requested commit; clarify itself still performs no action. It is false for filling, editing, checking, or selecting without committing, including explicit do-not-submit tasks and autosave UIs, and false for respond and plan_only. +- completion_requirements.download is true only when success requires WebBrain to write a file into browser/OS download storage. It is false for finding a download URL, link, button, instructions, or explanation, even when that result mentions a future download. Decide semantically across any language, never by matching words. This metadata only tightens completion evidence; it does not authorize tools, change mode, or bypass download permission. - Do not classify a follow-up as clarify merely because it refers to answers, drafts, or values already prepared in the ongoing task or currently present on the page. When the user authorizes using those existing values, classify execute and inspect them with read tools; clarify only after the available trusted context or runtime inspection cannot supply a required value. - 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. - allows_app_state_tool_evidence is true only when the requested work itself is reading/updating WebBrain scratchpad or progress ledger (not incidental bookkeeping). @@ -556,18 +562,27 @@ export function normalizePlan(obj, opts = {}) { const requiresSubmission = submissionBearingPlan ? (hasRequiresSubmission ? obj.requires_submission === true : null) : false; + const requiresDownload = executablePlan + && obj.completion_requirements?.download === true; + const completionRequirementCorrection = requiresDownload + && hasRequiresStateChange + && obj.requires_state_change === false + ? 'download_requires_state_change' + : null; const requiresStateChange = executablePlan ? ( !!obj.requires_state_change || requiresSubmission === true || !!normalizedScheduling - || canonicalPlanRequiresDownload(summary, steps) + || requiresDownload ) : false; return { request_kind: requestKind, requires_state_change: requiresStateChange, requires_submission: requiresSubmission, + completion_requirements: { download: requiresDownload }, + completion_requirement_correction: completionRequirementCorrection, allows_planner_shaped_result: requestKind === 'execute' && obj.allows_planner_shaped_result === true, allows_app_state_tool_evidence: requestKind === 'execute' && obj.allows_app_state_tool_evidence === true, read_scope: requestKind === 'execute' || (!opts.requireIntent && requestKind === null) @@ -633,6 +648,7 @@ function formatPlanConfidence(plan) { function appendPlanExecutionMetadata(lines, plan) { lines.push('### Completion requirements'); lines.push(`- Submission required: ${plan.requires_submission === true ? 'yes' : (plan.requires_submission === false ? 'no' : 'auto')}`); + lines.push(`- Download required: ${plan.completion_requirements?.download === true ? 'yes' : 'no'}`); lines.push(`- Read scope: ${normalizeReadScope(plan.read_scope) || 'none'}`); lines.push(''); diff --git a/src/firefox/src/network/network-tools.js b/src/firefox/src/network/network-tools.js index 0e26eef11..3b68f5adc 100644 --- a/src/firefox/src/network/network-tools.js +++ b/src/firefox/src/network/network-tools.js @@ -2261,12 +2261,24 @@ export async function downloadResourceFromPage(tabId, args = {}) { filename: downloadFilename, conflictAction: 'uniquify', }); + const info = await resolveDownloadInfo(downloadId); + const complete = info?.state === 'complete'; return { - success: true, + success: complete, downloadId, sourceUrl: r.isBlob ? '[blob]' : r.url, mime: r.mime || null, blob: !!r.isBlob, + ...(info?.filename ? { filename: info.filename } : {}), + ...(info?.state ? { state: info.state } : {}), + ...(info?.bytesReceived != null ? { bytesReceived: info.bytesReceived } : {}), + ...(info?.totalBytes != null ? { totalBytes: info.totalBytes } : {}), + ...(!complete ? { + pending: info?.state !== 'interrupted', + error: info?.state === 'interrupted' + ? `Download interrupted${info.error ? `: ${info.error}` : ' before completion.'}` + : `Download did not complete before timeout${info?.state ? ` (state: ${info.state})` : ''}.`, + } : {}), }; } catch (e) { return { success: false, error: e.message }; diff --git a/test/run.js b/test/run.js index 03c890a2d..7666ab382 100644 --- a/test/run.js +++ b/test/run.js @@ -326,10 +326,10 @@ const { transcribeAudio } = await import( // network-tools.js references chrome.* inside a try/catch at module load, so // it imports cleanly under Node — the storage init silently no-ops and // validateFetchUrl / registrableDomain are pure functions. -const { validateFetchUrl, registrableDomain, filenameFromContentDisposition: filenameFromContentDispositionCh, fetchUrl: fetchUrlCh, researchUrl: researchUrlCh, downloadFiles: downloadFilesCh, executeHttpSkillTool: executeHttpSkillToolCh } = await import( +const { validateFetchUrl, registrableDomain, filenameFromContentDisposition: filenameFromContentDispositionCh, fetchUrl: fetchUrlCh, researchUrl: researchUrlCh, downloadFiles: downloadFilesCh, downloadResourceFromPage: downloadResourceFromPageCh, executeHttpSkillTool: executeHttpSkillToolCh } = await import( 'file://' + path.join(ROOT, 'src/chrome/src/network/network-tools.js').replace(/\\/g, '/') ); -const { validateFetchUrl: validateFetchUrlFx, registrableDomain: registrableDomainFx, filenameFromContentDisposition: filenameFromContentDispositionFx, fetchUrl: fetchUrlFx, readPageSource: readPageSourceFx, researchUrl: researchUrlFx, downloadFiles: downloadFilesFx, executeHttpSkillTool: executeHttpSkillToolFx } = await import( +const { validateFetchUrl: validateFetchUrlFx, registrableDomain: registrableDomainFx, filenameFromContentDisposition: filenameFromContentDispositionFx, fetchUrl: fetchUrlFx, readPageSource: readPageSourceFx, researchUrl: researchUrlFx, downloadFiles: downloadFilesFx, downloadResourceFromPage: downloadResourceFromPageFx, executeHttpSkillTool: executeHttpSkillToolFx } = await import( 'file://' + path.join(ROOT, 'src/firefox/src/network/network-tools.js').replace(/\\/g, '/') ); const { firefoxRestrictedDomainForUrl, firefoxRestrictedDomainFailure, firefoxHostPermissionFailure } = await import( @@ -448,6 +448,8 @@ const { PLANNER_API_REPLAY_RULE, PLANNER_RESPONSE_ONLY_RULES, READ_SCOPE_SYSTEM_PROMPT, + PLANNER_RESPONSE_JSON_SCHEMA, + PLANNER_INTENT_RESPONSE_JSON_SCHEMA, buildPlannerSystemPrompt, buildPlannerIntentMessages, buildReadScopeMessages, @@ -467,6 +469,8 @@ const { PLANNER_API_REPLAY_RULE: PLANNER_API_REPLAY_RULE_FX, PLANNER_RESPONSE_ONLY_RULES: PLANNER_RESPONSE_ONLY_RULES_FX, READ_SCOPE_SYSTEM_PROMPT: READ_SCOPE_SYSTEM_PROMPT_FX, + PLANNER_RESPONSE_JSON_SCHEMA: PLANNER_RESPONSE_JSON_SCHEMA_FX, + PLANNER_INTENT_RESPONSE_JSON_SCHEMA: PLANNER_INTENT_RESPONSE_JSON_SCHEMA_FX, buildPlannerSystemPrompt: buildPlannerSystemPromptFx, buildPlannerMessages: buildPlannerMessagesFx, buildPlannerIntentMessages: buildPlannerIntentMessagesFx, @@ -52983,6 +52987,7 @@ function plannerIntentFixture({ requestKind = 'execute', requiresStateChange = false, requiresSubmission = false, + requiresDownload = false, allowsPlannerShapedResult = false, allowsAppStateToolEvidence = false, readScope = null, @@ -52996,6 +53001,7 @@ function plannerIntentFixture({ request_kind: requestKind, requires_state_change: requiresStateChange, requires_submission: requiresSubmission, + completion_requirements: { download: requiresDownload }, allows_planner_shaped_result: allowsPlannerShapedResult, allows_app_state_tool_evidence: allowsAppStateToolEvidence, read_scope: readScope || (requestKind === 'execute' ? 'visible_page' : 'none'), @@ -53856,6 +53862,105 @@ test('completion words do not mask mixed progress plus plan terminals', () => { } }); +test('download-required execution accepts only completed DOWNLOAD-capability evidence', () => { + for (const [index, AgentClass] of [AgentCh, AgentFx].entries()) { + const agent = new AgentClass({}); + const tabId = 8635 + index; + const state = agent._startPlanExecutionGuard(tabId, 'act', { + requestKind: 'execute', + requiresStateChange: false, + requiresDownload: true, + }); + assert.equal(state.requiresStateChange, true, `${AgentClass.name}: download did not force consequential evidence`); + + agent._markPlanExecutionToolCall(tabId, 'read_page', { success: true }); + agent._markPlanExecutionToolCall(tabId, 'click_ax', { success: true }, { consequential: true }); + agent._markPlanExecutionToolCall( + tabId, + 'download_social_media', + { success: true, completedCount: 0, urls: ['https://cdn.example/video.mp4'] }, + { consequential: true, download: true }, + ); + assert.equal(agent._executionEvidenceSatisfied(state), false, `${AgentClass.name}: read/click/URL evidence completed a download task`); + assert.equal(state.successfulDownloadToolCalls, 0, `${AgentClass.name}: media resolution counted as a saved file`); + + agent._markPlanExecutionToolCall(tabId, 'list_downloads', { + success: true, + downloads: [{ id: 999, state: 'complete' }], + }); + assert.equal(state.successfulDownloadToolCalls, 0, `${AgentClass.name}: unrelated historical download counted as task evidence`); + + const rejected = [ + { success: true, downloads: [{ success: true, downloadId: 11, state: 'in_progress' }] }, + { success: false, downloads: [{ success: false, downloadId: 12, state: 'interrupted' }] }, + { success: false, denied: true, error: 'permission denied' }, + { success: false, pending: true, downloadId: 13, state: 'in_progress' }, + ]; + for (const result of rejected) { + agent._markPlanExecutionToolCall(tabId, 'download_files', result, { consequential: true, download: true }); + } + assert.equal(state.successfulDownloadToolCalls, 0, `${AgentClass.name}: incomplete or denied download counted as complete`); + + agent._markPlanExecutionToolCall(tabId, 'list_downloads', { + success: true, + downloads: [{ id: 11, state: 'complete' }], + }); + assert.equal(state.successfulDownloadToolCalls, 1, `${AgentClass.name}: follow-up verification of the task download was rejected`); + assert.equal(agent._executionEvidenceSatisfied(state), true, `${AgentClass.name}: verified pending download did not satisfy the guard`); + + const followUpTabId = tabId + 20; + const followUpState = agent._startPlanExecutionGuard(followUpTabId, 'act', { + requestKind: 'execute', + requiresDownload: true, + }); + agent._markPlanExecutionToolCall(followUpTabId, 'read_page', { success: true }); + + const firstDecision = agent._planOnlyTerminalDecision( + followUpTabId, + 'The file is ready.', + { viaDone: true, outcome: 'success' }, + ); + assert.equal(firstDecision?.retry, true, `${AgentClass.name}: missing download evidence bypassed recovery`); + assert.match(firstDecision?.nudge || '', /requires a file to be downloaded/i, `${AgentClass.name}: download recovery was not specific`); + + agent._markPlanExecutionToolCall(followUpTabId, 'download_files', { + success: true, + downloads: [{ success: true, downloadId: 14, state: 'complete' }], + }, { consequential: true, download: true }); + assert.equal(followUpState.successfulDownloadToolCalls, 1, `${AgentClass.name}: completed download was not counted`); + assert.equal(agent._executionEvidenceSatisfied(followUpState), true, `${AgentClass.name}: completed download did not satisfy the guard`); + assert.equal( + agent._planOnlyTerminalDecision(followUpTabId, 'The file was downloaded.', { viaDone: true, outcome: 'success' }), + null, + `${AgentClass.name}: verified download was rejected`, + ); + } +}); + +test('download evidence recognizes completed core, screenshot, social, and skill results', () => { + for (const AgentClass of [AgentCh, AgentFx]) { + const agent = new AgentClass({}); + const accepted = [ + ['download_resource_from_page', { success: true, downloadId: 21, state: 'complete' }], + ['screenshot', { success: true, savedFile: { downloadId: 22, state: 'complete' } }], + ['download_social_media', { success: true, completedCount: 1 }], + ['download_public_media', { success: true, downloadId: 23, state: 'complete' }], + ]; + for (const [name, result] of accepted) { + assert.equal(agent._isSuccessfulDownloadEvidence(name, result), true, `${AgentClass.name}: ${name} completion rejected`); + } + const rejected = [ + ['download_resource_from_page', { success: true, downloadId: 31 }], + ['screenshot', { success: true, savedFile: { downloadId: 32, state: 'in_progress' } }], + ['download_social_media', { success: true, completedCount: 0 }], + ['download_public_media', { success: true, downloadId: 33, state: 'in_progress', pending: true }], + ]; + for (const [name, result] of rejected) { + assert.equal(agent._isSuccessfulDownloadEvidence(name, result), false, `${AgentClass.name}: ${name} incomplete result accepted`); + } + } +}); + test('planner-bypassed managed cloud runs never enable the execution guard', () => { for (const [index, AgentClass] of [AgentCh, AgentFx].entries()) { const agent = new AgentClass({}); @@ -53948,6 +54053,38 @@ test('trusted continuation carries consequential evidence without repeating the } }); +test('trusted continuation carries completed download evidence only for the same requirement', () => { + for (const [index, AgentClass] of [AgentCh, AgentFx].entries()) { + const agent = new AgentClass({}); + const tabId = 8647 + index; + const conversationId = `download_continuation_${index}`; + const gate = { + requestKind: 'execute', + requiresStateChange: true, + requiresDownload: true, + }; + agent.conversationIds.set(tabId, conversationId); + agent._startPlanExecutionGuard(tabId, 'act', gate); + agent._markPlanExecutionToolCall(tabId, 'download_resource_from_page', { + success: true, + downloadId: 41, + state: 'complete', + }, { consequential: true, download: true }); + agent._storeContinuationExecutionEvidence(tabId); + + const continued = agent._startPlanExecutionGuard(tabId, 'act', gate, { trustedContinuation: true }); + assert.equal(continued.successfulDownloadToolCalls, 1, `${AgentClass.name}: trusted continuation lost download evidence`); + assert.equal(agent._executionEvidenceSatisfied(continued), true, `${AgentClass.name}: carried download evidence was unusable`); + + agent._storeContinuationExecutionEvidence(tabId); + const changedRequirement = agent._startPlanExecutionGuard(tabId, 'act', { + ...gate, + requiresDownload: false, + }, { trustedContinuation: true }); + assert.equal(changedRequirement.successfulDownloadToolCalls, 0, `${AgentClass.name}: mismatched requirement reused download evidence`); + } +}); + test('trusted continuation carries verified submit state without permitting ordinary-turn reuse', () => { for (const [index, AgentClass] of [AgentCh, AgentFx].entries()) { const agent = new AgentClass({}); @@ -55008,6 +55145,7 @@ test('planner intent preserves Act and canonical execution fields when localized request_kind: 'execute', requires_state_change: false, requires_submission: false, + completion_requirements: { download: true }, allows_planner_shaped_result: false, allows_app_state_tool_evidence: true, read_scope: 'visible_page', @@ -55039,12 +55177,51 @@ test('planner intent preserves Act and canonical execution fields when localized assert.equal(gate.proceed, true, `${AgentClass.name}: recoverable localization blocked execution`); assert.equal(gate.requestKind, 'execute', `${AgentClass.name}: download intent was downgraded`); assert.equal(gate.plannerFailedContinueAct, undefined, `${AgentClass.name}: valid download plan was marked as a planner failure`); - assert.equal(gate.requiresStateChange, false, `${AgentClass.name}: localization recovery changed canonical execution metadata`); + assert.equal(gate.requiresStateChange, true, `${AgentClass.name}: download completion did not correct state-change evidence`); + assert.equal(gate.requiresDownload, true, `${AgentClass.name}: compact planner dropped download completion metadata`); assert.equal(warning, '', `${AgentClass.name}: recoverable localization emitted a planner failure warning`); } }); }); +test('full planner carries download completion metadata into the execution guard', async () => { + await withPlannerBrowserGlobals(async () => { + for (const [index, AgentClass] of [AgentCh, AgentFx].entries()) { + const agent = new AgentClass({ getActive: () => ({ name: 'planner-test', model: 'planner-test' }) }); + agent.setScheduledRunPolicy(8920 + index, { + requireConsequentialConfirmation: false, + autoApprovePlanReview: true, + }); + agent._chatWithCostAllowance = async () => ({ + content: plannerFixtureJson({ + requires_state_change: false, + completion_requirements: { download: true }, + summary: 'Download the selected video.', + steps: [{ id: '1', action: 'Download the selected video.', tools: ['download_files'] }], + }), + }); + const gate = await agent._runPlannerGate( + 8920 + index, + { role: 'user', content: 'Download the selected video.' }, + () => {}, + null, + null, + '', + { tabUrl: 'https://example.com/video', tabTitle: 'Video' }, + 'try', + 'act', + { locale: 'en' }, + ); + assert.equal(gate.proceed, true, `${AgentClass.name}: download plan was blocked`); + assert.equal(gate.requiresStateChange, true, `${AgentClass.name}: full planner did not correct state-change evidence`); + assert.equal(gate.requiresDownload, true, `${AgentClass.name}: full planner dropped download completion metadata`); + const guard = agent._startPlanExecutionGuard(8930 + index, 'act', gate); + assert.equal(guard.requiresStateChange, true, `${AgentClass.name}: guard did not treat download as state-changing`); + assert.equal(guard.requiresDownload, true, `${AgentClass.name}: guard lost download requirement`); + } + }); +}); + test('planner intent keeps execution authorized for plan-and-act and negated approval waits', async () => { await withPlannerBrowserGlobals(async () => { const tasks = [ @@ -58302,6 +58479,71 @@ test('download_files treats interrupted browser downloads as failed (chrome & fi } }); +test('download_resource_from_page waits for browser-reported completion (chrome & firefox)', async () => { + const originalChrome = globalThis.chrome; + const originalBrowser = globalThis.browser; + try { + globalThis.chrome = { + runtime: { lastError: null }, + scripting: { + async executeScript() { + return [{ result: { ok: true, url: 'https://example.com/report.pdf', isBlob: false, crossOrigin: false } }]; + }, + }, + downloads: { + download(_options, callback) { callback(8101); }, + search(_query, callback) { + callback([{ + id: 8101, + filename: '/Users/test/Downloads/report.pdf', + state: 'complete', + bytesReceived: 10, + totalBytes: 10, + }]); + }, + }, + }; + const chromeResult = await downloadResourceFromPageCh(42, { selector: '#report' }); + assert.equal(chromeResult.success, true); + assert.equal(chromeResult.downloadId, 8101); + assert.equal(chromeResult.state, 'complete'); + + globalThis.browser = { + storage: { + local: { async get() { return { downloadDirectory: '' }; } }, + }, + tabs: { + async executeScript() { + return [{ ok: true, url: 'https://example.com/report.pdf', isBlob: false, crossOrigin: false }]; + }, + }, + downloads: { + async download() { return 8102; }, + async search() { + return [{ + id: 8102, + filename: '/Users/test/Downloads/report.pdf', + state: 'interrupted', + error: 'NETWORK_FAILED', + bytesReceived: 4, + totalBytes: 10, + }]; + }, + }, + }; + const firefoxResult = await downloadResourceFromPageFx(42, { selector: '#report' }); + assert.equal(firefoxResult.success, false); + assert.equal(firefoxResult.downloadId, 8102); + assert.equal(firefoxResult.state, 'interrupted'); + assert.match(firefoxResult.error, /interrupted.*NETWORK_FAILED/i); + } finally { + if (originalChrome === undefined) delete globalThis.chrome; + else globalThis.chrome = originalChrome; + if (originalBrowser === undefined) delete globalThis.browser; + else globalThis.browser = originalBrowser; + } +}); + test('upload_file schema accepts downloadId and no longer hard-requires filePath (chrome)', () => { const tools = getToolsForModeCh('act', {}); const up = tools.find(t => t.function?.name === 'upload_file'); @@ -61165,6 +61407,88 @@ test('planner: canonical fields recover missing and partial localization without } }); +test('planner schemas require structured download completion metadata in both browsers', () => { + for (const [label, schema] of [ + ['chrome full', PLANNER_RESPONSE_JSON_SCHEMA], + ['chrome intent', PLANNER_INTENT_RESPONSE_JSON_SCHEMA], + ['firefox full', PLANNER_RESPONSE_JSON_SCHEMA_FX], + ['firefox intent', PLANNER_INTENT_RESPONSE_JSON_SCHEMA_FX], + ]) { + assert.ok(schema.required.includes('completion_requirements'), `${label}: completion requirements are optional`); + const completion = schema.properties.completion_requirements; + assert.equal(completion?.type, 'object', `${label}: completion requirements are not structured`); + assert.equal(completion?.additionalProperties, false, `${label}: completion requirements accept undeclared fields`); + assert.deepEqual(completion?.required, ['download'], `${label}: download requirement is optional`); + assert.equal(completion?.properties?.download?.type, 'boolean', `${label}: download requirement is not boolean`); + } +}); + +test('planner download completion metadata is language-neutral and does not infer from prose', () => { + const cases = [ + { task: 'download this video', download: true, locale: 'en' }, + { task: 'save the selected media locally', download: true, locale: 'en' }, + { task: 'find the URL to download the report', download: false, locale: 'en' }, + { task: 'find the download link', download: false, locale: 'en' }, + { task: 'explain how to download the report', download: false, locale: 'en' }, + { task: '把这个视频下载到本地', download: true, locale: 'zh-CN' }, + { task: '查找报告的下载链接', download: false, locale: 'zh-CN' }, + ]; + for (const [label, parse] of [['chrome', parsePlanFromContent], ['firefox', parsePlanFromContentFx]]) { + for (const fixture of cases) { + const raw = JSON.parse(plannerIntentFixture({ + requiresDownload: fixture.download, + locale: fixture.locale, + localizedSummary: fixture.task, + localizedSteps: [fixture.task], + })); + // Keep canonical prose deliberately identical. Only the structured field + // may determine the completion requirement. + raw.summary = 'Handle the requested resource safely.'; + raw.steps = [{ id: '1', action: 'Handle the requested resource safely.' }]; + const plan = parse(JSON.stringify(raw), { requireIntent: true, locale: fixture.locale }); + assert.equal(plan?.completion_requirements?.download, fixture.download, `${label}: ${fixture.task}`); + assert.equal(plan?.requires_state_change, fixture.download, `${label}: state-change correction for ${fixture.task}`); + assert.equal( + plan?.completion_requirement_correction, + fixture.download ? 'download_requires_state_change' : null, + `${label}: correction marker for ${fixture.task}`, + ); + } + + const legacy = parse(JSON.stringify({ + request_kind: 'execute', + requires_state_change: false, + requires_submission: false, + read_scope: 'visible_page', + summary: 'Download this report and save it locally.', + steps: [{ id: '1', action: 'Download this report and save it locally.' }], + localized: { + locale: 'en', + summary: 'Download this report and save it locally.', + steps: [{ id: '1', action: 'Download this report and save it locally.' }], + risks: [], + }, + }), { requireIntent: true, locale: 'en' }); + assert.equal(legacy?.completion_requirements?.download, false, `${label}: legacy prose armed download evidence`); + assert.equal(legacy?.requires_state_change, false, `${label}: legacy prose changed execution intent`); + } +}); + +test('planner correction trace payload is content-free in both browsers', () => { + for (const browser of ['chrome', 'firefox']) { + const source = fs.readFileSync(path.join(ROOT, `src/${browser}/src/agent/agent.js`), 'utf8'); + const start = source.indexOf('async _tracePlannerCompletionRequirementCorrection('); + const end = source.indexOf('\n }', start); + assert.ok(start >= 0 && end > start, `${browser}: correction trace helper missing`); + const helper = source.slice(start, end + 4); + const payload = /planner_completion_requirement_corrected',\s*\{([\s\S]*?)\n\s*\}\);/.exec(helper)?.[1] || ''; + assert.match(payload, /phase:/, `${browser}: trace omitted planner phase`); + assert.match(payload, /requirement:\s*'download'/, `${browser}: trace omitted requirement kind`); + assert.match(payload, /requiresStateChange:\s*true/, `${browser}: trace omitted runtime correction`); + assert.doesNotMatch(payload, /summary|steps|content|userMessage|localized|plan\?\./, `${browser}: trace exports planner text`); + } +}); + test('planner: parse JSON inside markdown fence', () => { const fenced = 'Here is the plan:\n```json\n{"summary":"Go back","steps":[],"memory":{"use_scratchpad":false,"scratchpad_notes":[],"use_progress_ledger":false,"progress_action":null},"scheduling":null,"risks":[],"mode":"act"}\n```'; const plan = parsePlanFromContent(fenced); @@ -61421,6 +61745,7 @@ function plannerFixtureJson(overrides = {}) { request_kind: 'execute', requires_state_change: false, requires_submission: false, + completion_requirements: { download: false }, allows_planner_shaped_result: false, allows_app_state_tool_evidence: false, read_scope: requestKind === 'execute' ? 'visible_page' : 'none', @@ -62446,6 +62771,89 @@ test('reviewed plan edits preserve only explicitly approved submission metadata' }); }); +test('reviewed plan step edits clear stale download completion metadata', async () => { + await withPlannerBrowserGlobals(async () => { + for (const [label, AgentClass] of [['chrome', AgentCh], ['firefox', AgentFx]]) { + const runReviewedPlan = async (tabId, markdownMode, editPlan) => { + const provider = { + promptTier: 'full', + model: 'planner-download-edit-test', + name: 'planner-download-edit-test', + }; + const agent = new AgentClass({ getActive: () => provider, getVisionProvider: async () => null }); + agent.setPlanReviewSettings({ mode: 'always' }); + agent._chatWithCostAllowance = async () => ({ + content: plannerFixtureJson({ + confidence: 0.99, + requires_state_change: false, + completion_requirements: { download: true }, + summary: 'Download the report.', + steps: [{ id: '1', action: 'Download the report.', tools: ['download_files'] }], + localized: { + locale: 'en', + summary: 'Download the report.', + steps: [{ id: '1', action: 'Download the report.' }], + risks: [], + }, + }), + }); + agent._waitForPlanReview = async (_tabId, _planId, _plan, compactMarkdown, _onUpdate, verboseMarkdown) => ({ + action: 'approve', + editedText: editPlan(markdownMode === 'verbose' ? verboseMarkdown : compactMarkdown), + markdownMode, + }); + return agent._runPlannerGate( + tabId, + { role: 'user', content: 'Download the report.' }, + () => {}, + null, + null, + '', + { tabUrl: 'https://example.test/report', tabTitle: 'Report' }, + 'try', + 'act', + { locale: 'en' }, + ); + }; + + const unchanged = await runReviewedPlan(label === 'chrome' ? 9242 : 9243, 'verbose', text => text); + assert.equal(unchanged.requiresDownload, true, `${label}: unchanged plan lost its download requirement`); + assert.equal(unchanged.requiresStateChange, true, `${label}: unchanged download stopped requiring a state change`); + + const unrelated = await runReviewedPlan( + label === 'chrome' ? 9244 : 9245, + 'verbose', + text => text.replace(/Confidence:\s*99%/i, 'Confidence: 98%'), + ); + assert.equal(unrelated.requiresDownload, true, `${label}: unrelated edit dropped download metadata`); + + const compactSteps = await runReviewedPlan( + label === 'chrome' ? 9246 : 9247, + 'compact', + text => text.replace(/^1\. Download the report\..*$/im, '1. Find the report link.'), + ); + assert.equal(compactSteps.requiresDownload, false, `${label}: compact step edit retained stale download metadata`); + assert.equal(compactSteps.requiresStateChange, false, `${label}: compact step edit retained a download-only mutation requirement`); + + const verboseSteps = await runReviewedPlan( + label === 'chrome' ? 9248 : 9249, + 'verbose', + text => text.replace(/^1\. Download the report\..*$/im, '1. Find the report link.'), + ); + assert.equal(verboseSteps.requiresDownload, false, `${label}: verbose step edit retained stale download metadata`); + assert.equal(verboseSteps.requiresStateChange, false, `${label}: verbose step edit retained a download-only mutation requirement`); + + const removed = await runReviewedPlan( + label === 'chrome' ? 9250 : 9251, + 'verbose', + text => text.replace(/(?:^|\n)\s*-\s*Download required:.*(?=\n|$)/i, ''), + ); + assert.equal(removed.requiresDownload, false, `${label}: removed download metadata stayed required`); + assert.equal(removed.requiresStateChange, false, `${label}: removed download metadata retained a download-only mutation requirement`); + } + }); +}); + test('plan before act: try is default while explicit off is preserved', () => { for (const [label, AgentClass] of [['chrome', AgentCh], ['firefox', AgentFx]]) { const agent = new AgentClass({}); @@ -64257,6 +64665,7 @@ test('planner gate: trusted recommended media action skips planner and pins read ); assert.equal(outcome.proceed, true, `${label} should proceed`); + assert.equal(outcome.requiresDownload, true, `${label} media fast path should require completed download evidence`); assert.equal(plannerCalls, 0, `${label} should skip the planner call`); assert.equal(agent.plannerFollowUpSkipTabs.has(tabId), false, `${label} should not arm the ordinary planner follow-up skip`); @@ -64397,6 +64806,7 @@ test('planner gate: trusted WebBrain social promotion actions skip planner and p ); assert.equal(outcome.proceed, true, `${label} ${fixture.name} should proceed`); + assert.equal(outcome.requiresDownload, false, `${label} ${fixture.name} should not gain a download requirement`); assert.equal(plannerCalls, 0, `${label} ${fixture.name} should skip the planner call`); const messages = agent.conversations.get(tabId); const idx = agent._findScratchpadIndex(messages);