Skip to content

Commit 2bfdab2

Browse files
committed
Fix planner intent recovery and cancellation context
1 parent a696af5 commit 2bfdab2

5 files changed

Lines changed: 752 additions & 24 deletions

File tree

src/chrome/src/agent/agent.js

Lines changed: 185 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ const TOKENS_PER_MILLION = 1_000_000;
8484
const DEFAULT_INPUT_COST_PER_MILLION_USD = 3;
8585
const DEFAULT_OUTPUT_COST_PER_MILLION_USD = 15;
8686
const 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

Comments
 (0)