@@ -84,6 +84,7 @@ const TOKENS_PER_MILLION = 1_000_000;
8484const DEFAULT_INPUT_COST_PER_MILLION_USD = 3;
8585const DEFAULT_OUTPUT_COST_PER_MILLION_USD = 15;
8686const DONE_OUTCOMES = new Set(['success', 'partial', 'failed']);
87+ const LOCAL_CANCELLATION_ASSISTANT_RE = /^\[?Stopped by user(?: before (?:the run started|executing requested tool calls))?\.?\]?$/;
8788// Appended to the system prompt of every selection-grounded model request.
8889// The scope hides the page and disables tools, so the model must explain the
8990// boundary instead of guessing when a follow-up reaches beyond the selection.
@@ -6787,12 +6788,37 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
67876788 * planner can resolve follow-up references ("continue", "open the first
67886789 * result"). Skips system / scratchpad / progress-ledger bookkeeping turns.
67896790 */
6791+ _isLocalCancellationText(content) {
6792+ return typeof content === 'string'
6793+ && LOCAL_CANCELLATION_ASSISTANT_RE.test(content.trim());
6794+ }
6795+
6796+ _localCancellationMessage(content) {
6797+ return {
6798+ role: 'assistant',
6799+ content,
6800+ webbrainLocalStatus: 'cancelled',
6801+ };
6802+ }
6803+
6804+ _isLocalConversationStatusMessage(message) {
6805+ if (message?.role !== 'assistant') return false;
6806+ return message.webbrainLocalStatus === 'cancelled'
6807+ || this._isLocalCancellationText(message.content);
6808+ }
6809+
6810+ _modelVisibleConversationMessages(messages) {
6811+ if (!Array.isArray(messages)) return [];
6812+ return messages.filter(message => !this._isLocalConversationStatusMessage(message));
6813+ }
6814+
67906815 _buildPlannerHistoryDigest(messages, maxChars = 1500) {
67916816 if (!Array.isArray(messages) || messages.length === 0) return '';
67926817 const lines = [];
67936818 for (const m of messages.slice(-10)) {
67946819 if (!m || (m.role !== 'user' && m.role !== 'assistant')) continue;
67956820 if (this._isPinnedAgentStateMessage(m)) continue;
6821+ if (this._isLocalConversationStatusMessage(m)) continue;
67966822 const rawText = userMessageToText(m);
67976823 const taskText = m.role === 'user' ? this._stripInjectedTaskContext(rawText) : rawText;
67986824 const text = sanitizePlannerText(taskText, 300, { collapseWhitespace: true });
@@ -7238,6 +7264,60 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
72387264 ];
72397265 }
72407266
7267+ _plannerIntentConsistencyIssue(plan) {
7268+ if (!plan || !Array.isArray(plan.steps)) return null;
7269+ const tools = [...new Set(
7270+ plan.steps.flatMap(step => Array.isArray(step?.tools) ? step.tools : [])
7271+ .map(tool => String(tool || '').trim())
7272+ .filter(Boolean)
7273+ )];
7274+ if (plan.request_kind === 'respond' && tools.length > 0) {
7275+ return { kind: 'respond_with_tools', tools };
7276+ }
7277+ const executionTools = tools.filter(tool => tool !== 'done');
7278+ if (plan.request_kind === 'plan_only' && executionTools.length > 0) {
7279+ return { kind: 'plan_only_with_execution_tools', tools: executionTools };
7280+ }
7281+ return null;
7282+ }
7283+
7284+ _plannerIntentConsistencyRepairMessages(plannerMessages, issue) {
7285+ const issueKind = issue?.kind === 'respond_with_tools'
7286+ || issue?.kind === 'plan_only_with_execution_tools'
7287+ ? issue.kind
7288+ : 'unknown';
7289+ return [
7290+ ...plannerMessages,
7291+ {
7292+ role: 'user',
7293+ content:
7294+ '/no_think\n' +
7295+ `The previous JSON was parseable but its intent needs one consistency check (${issueKind}). ` +
7296+ 'Re-read only the authoritative User task above and output one corrected JSON object. ' +
7297+ 'Keep plan_only only when the user asked merely for a plan, outline, strategy, or discussion without authorizing execution. ' +
7298+ 'Use execute when producing the requested answer needs a fresh page, browser, network, memory, or scheduling tool; read-only execution still has requires_state_change false. ' +
7299+ 'Use respond only when existing conversation or working-note context is sufficient and list no tools. ' +
7300+ 'Do not infer permission for a mutation that the current user task did not authorize. No prose, markdown, tool calls, or reasoning text.',
7301+ },
7302+ ];
7303+ }
7304+
7305+ _plannerIntentUnresolvedConsistencyIssue(plan, recheckedIssueKind = null) {
7306+ const issue = this._plannerIntentConsistencyIssue(plan);
7307+ if (!issue) return null;
7308+ // Repeating plan_only after the focused semantic recheck confirms that the
7309+ // user asked only for a plan. A respond plan that still lists tools can
7310+ // never be routed response-only, and a new issue after either repair has
7311+ // not received the focused consistency check.
7312+ if (
7313+ issue.kind === 'plan_only_with_execution_tools'
7314+ && recheckedIssueKind === issue.kind
7315+ ) {
7316+ return null;
7317+ }
7318+ return issue;
7319+ }
7320+
72417321 _plannerIntentFailureMessage(runOptions = {}) {
72427322 return sanitizePlannerText(
72437323 runOptions?.intentFailureMessage
@@ -7412,8 +7492,11 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
74127492 }
74137493 if (this._checkAbort(tabId)) return { proceed: false, message: '[Stopped by user]', reason: 'cancelled' };
74147494
7495+ let plannerRepairUsed = false;
7496+ let consistencyRepairKind = null;
74157497 let plan = parsePlanFromContent(result.content, { requireIntent: true, locale });
74167498 if (!plan) {
7499+ plannerRepairUsed = true;
74177500 onUpdate('thinking', { step: plannerStep, note: 'Understanding request… retrying JSON output' });
74187501 result = await this._chatWithCostAllowance(
74197502 provider,
@@ -7424,10 +7507,29 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
74247507 );
74257508 plan = parsePlanFromContent(result.content, { requireIntent: true, locale });
74267509 }
7510+ const consistencyIssue = !plannerRepairUsed
7511+ ? this._plannerIntentConsistencyIssue(plan)
7512+ : null;
7513+ if (consistencyIssue) {
7514+ plannerRepairUsed = true;
7515+ consistencyRepairKind = consistencyIssue.kind;
7516+ onUpdate('thinking', { step: plannerStep, note: 'Understanding request… rechecking tool-dependent intent' });
7517+ result = await this._chatWithCostAllowance(
7518+ provider,
7519+ this._plannerIntentConsistencyRepairMessages(plannerMessages, consistencyIssue),
7520+ this._plannerChatOptions(provider, true, true),
7521+ costState,
7522+ { tabId, generationName: 'planner_intent' },
7523+ );
7524+ plan = parsePlanFromContent(result.content, { requireIntent: true, locale });
7525+ }
74277526 if (this._checkAbort(tabId)) return { proceed: false, message: '[Stopped by user]', reason: 'cancelled' };
74287527 if (!plan) {
74297528 return this._plannerReadOnlyFallback(runOptions, onUpdate);
74307529 }
7530+ if (this._plannerIntentUnresolvedConsistencyIssue(plan, consistencyRepairKind)) {
7531+ return this._plannerReadOnlyFallback(runOptions, onUpdate);
7532+ }
74317533 if (plan.request_kind === 'respond') {
74327534 return {
74337535 proceed: true,
@@ -7524,12 +7626,15 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
75247626 if (this._checkAbort(tabId)) {
75257627 return { proceed: false, message: '[Stopped by user]' };
75267628 }
7629+ let plannerRepairUsed = false;
7630+ let consistencyRepairKind = null;
75277631 let plan = parsePlanFromContent(result.content, { requireIntent: true, locale });
75287632 // Retry whenever the first attempt yields no parseable plan — empty
75297633 // output, thinking-only output, OR non-JSON prose ("Sure, here's the
75307634 // plan…"). The repair prompt exists precisely to coerce JSON out of that
75317635 // prose case, so it must not be gated on emptiness/reasoning. (#1)
75327636 if (!plan) {
7637+ plannerRepairUsed = true;
75337638 onUpdate('thinking', { step: 0, note: 'Planning… retrying JSON output' });
75347639 result = await this._chatWithCostAllowance(
75357640 provider,
@@ -7540,6 +7645,22 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
75407645 );
75417646 plan = parsePlanFromContent(result.content, { requireIntent: true, locale });
75427647 }
7648+ const consistencyIssue = !plannerRepairUsed
7649+ ? this._plannerIntentConsistencyIssue(plan)
7650+ : null;
7651+ if (consistencyIssue) {
7652+ plannerRepairUsed = true;
7653+ consistencyRepairKind = consistencyIssue.kind;
7654+ onUpdate('thinking', { step: 0, note: 'Planning… rechecking tool-dependent intent' });
7655+ result = await this._chatWithCostAllowance(
7656+ provider,
7657+ this._plannerIntentConsistencyRepairMessages(plannerMessages, consistencyIssue),
7658+ this._plannerChatOptions(provider, true),
7659+ costState,
7660+ { tabId, generationName: 'planner' },
7661+ );
7662+ plan = parsePlanFromContent(result.content, { requireIntent: true, locale });
7663+ }
75437664 // The retry above is a paid LLM call that does not honor the abort flag
75447665 // itself; re-check before pinning the plan or showing the review card so
75457666 // a Stop pressed during the retry isn't ignored until after approval. (#2)
@@ -7551,6 +7672,11 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
75517672 ? this._strictPlannerFailure(onUpdate)
75527673 : this._plannerReadOnlyFallback(runOptions, onUpdate);
75537674 }
7675+ if (this._plannerIntentUnresolvedConsistencyIssue(plan, consistencyRepairKind)) {
7676+ return this._normalizePlanBeforeActMode(plannerMode) === 'strict'
7677+ ? this._strictPlannerFailure(onUpdate)
7678+ : this._plannerReadOnlyFallback(runOptions, onUpdate);
7679+ }
75547680 if (plan.request_kind === 'respond') {
75557681 return {
75567682 proceed: true,
@@ -7756,7 +7882,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
77567882 _consumeContextOnlyAbort(tabId, messages, onUpdate) {
77577883 if (!this._checkAbort(tabId)) return null;
77587884 const content = '[Stopped by user]';
7759- messages.push({ role: 'assistant', content } );
7885+ messages.push(this._localCancellationMessage( content) );
77607886 onUpdate('text', { content, replace: true });
77617887 onUpdate('warning', { message: 'Stopped by user.' });
77627888 this._persist(tabId);
@@ -11191,6 +11317,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
1119111317 ? (carried.successfulRequiredSchedulingToolCalls || 0)
1119211318 : 0,
1119311319 recoveryAttempted: false,
11320+ staleCancellationRecoveryAttempted: false,
1119411321 };
1119511322 this._planExecutionGuards.set(tabId, state);
1119611323 if (carryMatches && carried.completionSubmitState) {
@@ -11404,6 +11531,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
1140411531 state,
1140511532 { ignoreFuturePromise: terminalFailure },
1140611533 );
11534+ const staleCancellation = !viaDone && this._isLocalCancellationText(content);
1140711535 const missingRequiredSchedulingTool = !terminalFailure
1140811536 && !!state.requiredSchedulingTool
1140911537 && state.successfulRequiredSchedulingToolCalls === 0;
@@ -11416,9 +11544,15 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
1141611544 if (!invalidPlainFinal && !invalidDone) return null;
1141711545 if (!state.recoveryAttempted) {
1141811546 state.recoveryAttempted = true;
11547+ state.staleCancellationRecoveryAttempted = staleCancellation;
1141911548 return {
1142011549 retry: true,
11421- nudge: missingRequiredSchedulingTool
11550+ retryAssistantContent: staleCancellation
11551+ ? '[Stale local cancellation status omitted from execution context.]'
11552+ : null,
11553+ nudge: staleCancellation
11554+ ? '[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.]'
11555+ : missingRequiredSchedulingTool
1142211556 ? `[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.]`
1142311557 : '[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.]',
1142411558 };
@@ -11430,6 +11564,14 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
1143011564 };
1143111565 }
1143211566 const hasSuccessfulToolEvidence = state.successfulTaskToolCalls > 0;
11567+ if (staleCancellation && state.staleCancellationRecoveryAttempted) {
11568+ return {
11569+ failure: hasSuccessfulToolEvidence
11570+ ? '[Agent stopped because the model repeated a stale cancellation status after one recovery nudge. No current user stop was received. Some task tools completed, but final completion was not verified. Inspect the current state before retrying to avoid duplicate side effects.]'
11571+ : '[Agent stopped because the model repeated a stale cancellation status after one recovery nudge. No current user stop was received and no successful action was verified.]',
11572+ status: 'plan_only_output',
11573+ };
11574+ }
1143311575 return {
1143411576 failure: hasSuccessfulToolEvidence
1143511577 ? '[Agent stopped because the model returned another plain terminal or a plan/promise after one recovery nudge. Some task tools completed, but final completion was not verified. Inspect the current state before retrying to avoid duplicate side effects.]'
@@ -11957,8 +12099,18 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
1195712099 // Strip the scratchpad out of both slices — we re-pin a single copy of
1195812100 // it in the rebuild step below. Without this we'd either lose it (if it
1195912101 // fell into oldMessages and got summarized away) or duplicate it.
11960- const oldMessages = oldMessagesRaw.filter(m => m !== scheduledResumeMsg && !this._isScheduledResumeTurn(m.content) && !this._isPinnedAgentStateMessage(m));
11961- const recentMessages = recentMessagesRaw.filter(m => m !== scheduledResumeMsg && !this._isScheduledResumeTurn(m.content) && !this._isPinnedAgentStateMessage(m));
12102+ const oldMessages = oldMessagesRaw.filter(m => (
12103+ m !== scheduledResumeMsg
12104+ && !this._isScheduledResumeTurn(m.content)
12105+ && !this._isPinnedAgentStateMessage(m)
12106+ && !this._isLocalConversationStatusMessage(m)
12107+ ));
12108+ const recentMessages = recentMessagesRaw.filter(m => (
12109+ m !== scheduledResumeMsg
12110+ && !this._isScheduledResumeTurn(m.content)
12111+ && !this._isPinnedAgentStateMessage(m)
12112+ && !this._isLocalConversationStatusMessage(m)
12113+ ));
1196212114
1196312115 // Boundary fix: the recent slice must not begin in the middle of a
1196412116 // tool-call group. If the cutoff lands right after an assistant
@@ -12600,7 +12752,9 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
1260012752 currentUserMessage = null,
1260112753 priorMessageSet = null,
1260212754 ) {
12603- if (runOptions?.sourceGrounding !== SELECTION_ONLY_SOURCE_GROUNDING) return messages;
12755+ if (runOptions?.sourceGrounding !== SELECTION_ONLY_SOURCE_GROUNDING) {
12756+ return this._modelVisibleConversationMessages(messages);
12757+ }
1260412758
1260512759 const systemMessage = messages[0]?.role === 'system' ? messages[0] : null;
1260612760 const currentTurnIndex = currentUserMessage ? messages.indexOf(currentUserMessage) : -1;
@@ -12617,7 +12771,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
1261712771 const scopedSystemMessage = systemMessage && typeof systemMessage.content === 'string'
1261812772 ? { ...systemMessage, content: `${systemMessage.content}\n\n${SELECTION_SCOPE_SYSTEM_NOTE}` }
1261912773 : systemMessage;
12620- return [
12774+ return this._modelVisibleConversationMessages( [
1262112775 ...(scopedSystemMessage ? [scopedSystemMessage] : []),
1262212776 // Selection shortcuts run in Ask mode and never need durable agent
1262312777 // notes. Exclude them structurally as well as by the persisted prior
@@ -12626,7 +12780,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
1262612780 ...currentRunMessages.filter(message =>
1262712781 message !== systemMessage && !this._isPinnedAgentStateMessage(message)
1262812782 ),
12629- ];
12783+ ]) ;
1263012784 }
1263112785
1263212786 _emergencyTrimModelCopy(messages) {
@@ -19320,7 +19474,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
1932019474 finalResponse = finalResponse || '[Stopped by user]';
1932119475 _traceStatus = 'cancelled';
1932219476 onUpdate('warning', { message: 'Stopped by user.' });
19323- messages.push({ role: 'assistant', content: finalResponse } );
19477+ messages.push(this._localCancellationMessage( finalResponse) );
1932419478 break;
1932519479 }
1932619480
@@ -19466,7 +19620,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
1946619620 : '[Stopped by user]';
1946719621 _traceStatus = 'cancelled';
1946819622 onUpdate('warning', { message: 'Stopped by user.' });
19469- messages.push({ role: 'assistant', content: finalResponse } );
19623+ messages.push(this._localCancellationMessage( finalResponse) );
1947019624 break;
1947119625 }
1947219626
@@ -19640,7 +19794,15 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
1964019794 }
1964119795 const planOnlyDecision = this._planOnlyTerminalDecision(tabId, result.content);
1964219796 if (planOnlyDecision?.retry) {
19643- messages.push(this._withResponseItems({ role: 'assistant', content: result.content }, result.responseItems, result.reasoningContent, provider));
19797+ messages.push(this._withResponseItems(
19798+ {
19799+ role: 'assistant',
19800+ content: planOnlyDecision.retryAssistantContent ?? result.content,
19801+ },
19802+ planOnlyDecision.retryAssistantContent ? null : result.responseItems,
19803+ planOnlyDecision.retryAssistantContent ? '' : result.reasoningContent,
19804+ provider,
19805+ ));
1964419806 messages.push({ role: 'user', content: planOnlyDecision.nudge });
1964519807 // Clear any already-rendered plan/promise so recovery does not leave
1964619808 // rejected terminal text in the assistant bubble (and so run-complete
@@ -19906,8 +20068,11 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
1990620068
1990720069 while (steps < this.maxSteps) {
1990820070 if (this._checkAbort(tabId)) {
20071+ const content = '[Stopped by user]';
20072+ messages.push(this._localCancellationMessage(content));
20073+ this._persist(tabId);
1990920074 onUpdate('warning', { message: 'Stopped by user.' });
19910- return finish('[Stopped by user]' , 'cancelled');
20075+ return finish(content , 'cancelled');
1991120076 }
1991220077
1991320078 if (steps > 0 && !selectionOnly) {
@@ -20148,7 +20313,15 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
2014820313 }
2014920314 const planOnlyDecision = this._planOnlyTerminalDecision(tabId, fullText);
2015020315 if (planOnlyDecision?.retry) {
20151- messages.push(this._withResponseItems({ role: 'assistant', content: fullText }, responseItems, reasoningContent, provider));
20316+ messages.push(this._withResponseItems(
20317+ {
20318+ role: 'assistant',
20319+ content: planOnlyDecision.retryAssistantContent ?? fullText,
20320+ },
20321+ planOnlyDecision.retryAssistantContent ? null : responseItems,
20322+ planOnlyDecision.retryAssistantContent ? '' : reasoningContent,
20323+ provider,
20324+ ));
2015220325 messages.push({ role: 'user', content: planOnlyDecision.nudge });
2015320326 // Streamed plan text already landed via text_delta. Replace it before
2015420327 // the recovery turn so later deltas do not append onto the plan and
0 commit comments