Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 42 additions & 10 deletions src/chrome/src/agent/planner.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,25 @@ import { sanitizeText } from './text-sanitize.js';

const UNTRUSTED_PAGE_CONTENT_TAG_RE = /<\/?untrusted_page_content\b[^>]*>/gi;
const REQUEST_KINDS = new Set(['execute', 'respond', 'plan_only', 'clarify']);
const DOWNLOAD_PLAN_TOOLS = new Set([
'download_file',
'download_files',
'download_public_media',
'download_resource_from_page',
'download_social_media',
]);

function canonicalPlanRequiresDownload(summary, steps) {
if (steps.some(step => step.tools.some(tool => DOWNLOAD_PLAN_TOOLS.has(tool)))) return true;
// Canonical planner fields are required to be English. Match an explicit
// download action, but not a read-only request to find a download link. A
// bounded local-save phrase covers compact plans, whose steps omit tools.
const actionText = [summary, ...steps.map(step => step.action)].join('\n');
return actionText.split('\n').some(line => (
/(?:^|[.!?]\s*|\b(?:and|then|to)\s+)download(?:s|ed|ing)?\s+(?!(?:links?|urls?|buttons?|pages?|instructions?)\b)\S/i.test(line)
|| /\b(?:sav(?:e|es|ed|ing)|stor(?:e|es|ed|ing))\b[^\n.!?]{0,120}\b(?:locally|to (?:the )?(?:downloads? folder|disk)|on (?:the )?(?:device|computer|disk))\b/i.test(line)
));
}

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.';

Expand Down Expand Up @@ -354,33 +373,48 @@ export function normalizePlan(obj, opts = {}) {
const normalizedScheduling = tool === 'schedule_task' || tool === 'schedule_resume'
? { tool, hint: sanitizeText(scheduling.hint, 300) }
: null;
const risks = Array.isArray(obj.risks)
? obj.risks.map((risk) => sanitizeText(risk, 200)).filter(Boolean).slice(0, 6)
: [];
const localizedInput = obj.localized && typeof obj.localized === 'object' ? obj.localized : {};
const localizedSteps = Array.isArray(localizedInput.steps)
const providedLocalizedSteps = Array.isArray(localizedInput.steps)
? localizedInput.steps.slice(0, 12).map((step, i) => ({
id: sanitizeText(step?.id || String(i + 1), 20) || String(i + 1),
action: sanitizeText(step?.action, 300),
})).filter((step) => step.action)
: [];
const localizedSummary = sanitizeText(localizedInput.summary, 400);
const localizedStepsById = new Map(providedLocalizedSteps.map(step => [step.id, step]));
const localizedSteps = steps.map(step => ({
id: step.id,
action: localizedStepsById.get(step.id)?.action
|| step.action,
}));
const providedLocalizedRisks = Array.isArray(localizedInput.risks)
? localizedInput.risks.slice(0, 6).map((risk) => sanitizeText(risk, 200))
: [];
const requestedLocale = normalizePlannerLocale(opts.locale || localizedInput.locale);
if (opts.requireIntent) {
if (!localizedSummary) return null;
if (requestKind !== 'clarify' && requestKind !== 'respond' && (steps.length === 0 || localizedSteps.length === 0)) return null;
if (requestKind === 'clarify' && !localizedSummary) return null;
if (requestKind !== 'clarify' && requestKind !== 'respond' && steps.length === 0) return null;
}
const localized = {
locale: requestedLocale,
summary: localizedSummary || summary,
steps: localizedSteps,
risks: Array.isArray(localizedInput.risks)
? localizedInput.risks.map((risk) => sanitizeText(risk, 200)).filter(Boolean).slice(0, 6)
: [],
risks: risks.map((risk, index) => providedLocalizedRisks[index] || risk),
};
const submissionBearingPlan = executablePlan || requestKind === 'clarify';
const requiresSubmission = submissionBearingPlan
? (hasRequiresSubmission ? obj.requires_submission === true : null)
: false;
const requiresStateChange = executablePlan
? (!!obj.requires_state_change || requiresSubmission === true || !!normalizedScheduling)
? (
!!obj.requires_state_change
|| requiresSubmission === true
|| !!normalizedScheduling
|| canonicalPlanRequiresDownload(summary, steps)
)
: false;
return {
request_kind: requestKind,
Expand All @@ -407,9 +441,7 @@ export function normalizePlan(obj, opts = {}) {
: 'auto',
},
scheduling: executablePlan ? normalizedScheduling : null,
risks: Array.isArray(obj.risks)
? obj.risks.map((r) => sanitizeText(r, 200)).filter(Boolean).slice(0, 6)
: [],
risks,
localized,
mode: 'act',
};
Expand Down
52 changes: 42 additions & 10 deletions src/firefox/src/agent/planner.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,25 @@ import { sanitizeText } from './text-sanitize.js';

const UNTRUSTED_PAGE_CONTENT_TAG_RE = /<\/?untrusted_page_content\b[^>]*>/gi;
const REQUEST_KINDS = new Set(['execute', 'respond', 'plan_only', 'clarify']);
const DOWNLOAD_PLAN_TOOLS = new Set([
'download_file',
'download_files',
'download_public_media',
'download_resource_from_page',
'download_social_media',
]);

function canonicalPlanRequiresDownload(summary, steps) {
if (steps.some(step => step.tools.some(tool => DOWNLOAD_PLAN_TOOLS.has(tool)))) return true;
// Canonical planner fields are required to be English. Match an explicit
// download action, but not a read-only request to find a download link. A
// bounded local-save phrase covers compact plans, whose steps omit tools.
const actionText = [summary, ...steps.map(step => step.action)].join('\n');
return actionText.split('\n').some(line => (
/(?:^|[.!?]\s*|\b(?:and|then|to)\s+)download(?:s|ed|ing)?\s+(?!(?:links?|urls?|buttons?|pages?|instructions?)\b)\S/i.test(line)
|| /\b(?:sav(?:e|es|ed|ing)|stor(?:e|es|ed|ing))\b[^\n.!?]{0,120}\b(?:locally|to (?:the )?(?:downloads? folder|disk)|on (?:the )?(?:device|computer|disk))\b/i.test(line)
));
}

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.';

Expand Down Expand Up @@ -354,33 +373,48 @@ export function normalizePlan(obj, opts = {}) {
const normalizedScheduling = tool === 'schedule_task' || tool === 'schedule_resume'
? { tool, hint: sanitizeText(scheduling.hint, 300) }
: null;
const risks = Array.isArray(obj.risks)
? obj.risks.map((risk) => sanitizeText(risk, 200)).filter(Boolean).slice(0, 6)
: [];
const localizedInput = obj.localized && typeof obj.localized === 'object' ? obj.localized : {};
const localizedSteps = Array.isArray(localizedInput.steps)
const providedLocalizedSteps = Array.isArray(localizedInput.steps)
? localizedInput.steps.slice(0, 12).map((step, i) => ({
id: sanitizeText(step?.id || String(i + 1), 20) || String(i + 1),
action: sanitizeText(step?.action, 300),
})).filter((step) => step.action)
: [];
const localizedSummary = sanitizeText(localizedInput.summary, 400);
const localizedStepsById = new Map(providedLocalizedSteps.map(step => [step.id, step]));
const localizedSteps = steps.map(step => ({
id: step.id,
action: localizedStepsById.get(step.id)?.action
|| step.action,
}));
const providedLocalizedRisks = Array.isArray(localizedInput.risks)
? localizedInput.risks.slice(0, 6).map((risk) => sanitizeText(risk, 200))
: [];
const requestedLocale = normalizePlannerLocale(opts.locale || localizedInput.locale);
if (opts.requireIntent) {
if (!localizedSummary) return null;
if (requestKind !== 'clarify' && requestKind !== 'respond' && (steps.length === 0 || localizedSteps.length === 0)) return null;
if (requestKind === 'clarify' && !localizedSummary) return null;
if (requestKind !== 'clarify' && requestKind !== 'respond' && steps.length === 0) return null;
}
const localized = {
locale: requestedLocale,
summary: localizedSummary || summary,
steps: localizedSteps,
risks: Array.isArray(localizedInput.risks)
? localizedInput.risks.map((risk) => sanitizeText(risk, 200)).filter(Boolean).slice(0, 6)
: [],
risks: risks.map((risk, index) => providedLocalizedRisks[index] || risk),
};
const submissionBearingPlan = executablePlan || requestKind === 'clarify';
const requiresSubmission = submissionBearingPlan
? (hasRequiresSubmission ? obj.requires_submission === true : null)
: false;
const requiresStateChange = executablePlan
? (!!obj.requires_state_change || requiresSubmission === true || !!normalizedScheduling)
? (
!!obj.requires_state_change
|| requiresSubmission === true
|| !!normalizedScheduling
|| canonicalPlanRequiresDownload(summary, steps)
)
: false;
return {
request_kind: requestKind,
Expand All @@ -407,9 +441,7 @@ export function normalizePlan(obj, opts = {}) {
: 'auto',
},
scheduling: executablePlan ? normalizedScheduling : null,
risks: Array.isArray(obj.risks)
? obj.risks.map((r) => sanitizeText(r, 200)).filter(Boolean).slice(0, 6)
: [],
risks,
localized,
mode: 'act',
};
Expand Down
151 changes: 151 additions & 0 deletions test/run.js
Original file line number Diff line number Diff line change
Expand Up @@ -54524,6 +54524,56 @@ test('planner intent degrades to a localized read-only turn after one repair', a
});
});

test('planner intent preserves Act for a canonical download plan missing localized display fields', async () => {
await withPlannerBrowserGlobals(async () => {
for (const [index, AgentClass] of [AgentCh, AgentFx].entries()) {
const agent = new AgentClass({ getActive: () => ({ name: 'intent-test', model: 'intent-test' }) });
let calls = 0;
let warning = '';
agent._chatWithCostAllowance = async () => {
calls += 1;
return {
content: JSON.stringify({
request_kind: 'execute',
requires_state_change: false,
requires_submission: false,
allows_planner_shaped_result: false,
allows_app_state_tool_evidence: true,
read_scope: 'visible_page',
summary: 'Find and download the video from the current X tweet page',
steps: [
{ id: '1', action: 'Read the current page to find video elements or download links' },
{ id: '2', action: 'Extract the direct video URL and download it' },
],
memory: { use_progress_ledger: false, progress_action: null },
scheduling: null,
risks: ['The video source may need to be resolved before downloading'],
}),
};
};

const gate = await agent._runPlannerIntentGate(
8685 + index,
{ role: 'user', content: 'download this video' },
(type, data) => { if (type === 'warning') warning = data?.message || ''; },
null,
null,
'',
{ tabUrl: 'https://x.com/example/status/1', tabTitle: 'Example post' },
'act',
{ locale: 'tr' },
);

assert.equal(calls, 1, `${AgentClass.name}: missing display localization triggered an unnecessary repair`);
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.readOnlyFallback, undefined, `${AgentClass.name}: download plan fell back to Ask`);
assert.equal(gate.requiresStateChange, true, `${AgentClass.name}: canonical download action did not require mutation evidence`);
assert.equal(warning, '', `${AgentClass.name}: recoverable localization emitted a planner failure warning`);
}
});
});

test('planner intent keeps execution authorized for plan-and-act and negated approval waits', async () => {
await withPlannerBrowserGlobals(async () => {
const tasks = [
Expand Down Expand Up @@ -60582,6 +60632,107 @@ test('planner: parse and format structured plan', () => {
}
});

test('planner: canonical fields recover missing localization and explicit download actions require state change', () => {
const tracePlan = JSON.stringify({
request_kind: 'execute',
requires_state_change: false,
requires_submission: false,
allows_planner_shaped_result: false,
allows_app_state_tool_evidence: true,
read_scope: 'visible_page',
summary: 'Find and download the video from the current X tweet page',
steps: [
{ id: '1', action: 'Read the current page to find video elements or download links' },
{ id: '2', action: 'Extract the direct video URL and download it' },
],
memory: { use_progress_ledger: false, progress_action: null },
scheduling: null,
risks: ['The video source may need to be resolved before downloading'],
});

for (const [label, parse] of [['chrome', parsePlanFromContent], ['firefox', parsePlanFromContentFx]]) {
const plan = parse(tracePlan, { requireIntent: true, locale: 'tr' });
assert.ok(plan, `${label}: missing localized display fields invalidated canonical intent`);
assert.equal(plan.localized.locale, 'tr', `${label}: requested display locale was lost`);
assert.equal(plan.localized.summary, plan.summary, `${label}: canonical summary did not backfill localized display text`);
assert.deepEqual(
plan.localized.steps,
plan.steps.map(step => ({ id: step.id, action: step.action })),
`${label}: canonical steps did not backfill localized display text`,
);
assert.deepEqual(plan.localized.risks, plan.risks, `${label}: canonical risks did not backfill localized display text`);
assert.equal(plan.requires_state_change, true, `${label}: explicit canonical download action stayed read-only`);

const partialLocalization = JSON.parse(tracePlan);
partialLocalization.risks = ['First canonical risk', 'Second canonical risk'];
partialLocalization.localized = {
locale: 'tr',
summary: 'Geçerli videoyu indir',
steps: [{ id: '2', action: 'Doğrudan video adresini çıkar ve indir' }],
risks: ['', 'İkinci risk'],
};
const partial = parse(JSON.stringify(partialLocalization), { requireIntent: true, locale: 'tr' });
assert.equal(partial?.localized.steps[0]?.action, partial?.steps[0]?.action, `${label}: missing localized step did not fall back by id`);
assert.equal(partial?.localized.steps[1]?.action, 'Doğrudan video adresini çıkar ve indir', `${label}: supplied localized step was discarded`);
assert.deepEqual(
partial?.localized.risks,
['First canonical risk', 'İkinci risk'],
`${label}: localized risk holes shifted translations away from their canonical positions`,
);

const malformedClarification = parse(JSON.stringify({
request_kind: 'clarify',
requires_state_change: false,
requires_submission: false,
read_scope: 'none',
summary: 'Ask the user which account should receive the transfer.',
steps: [],
risks: [],
}), { requireIntent: true, locale: 'tr' });
assert.equal(malformedClarification, null, `${label}: clarification without the actual localized question bypassed repair`);

const linkLookup = parse(JSON.stringify({
request_kind: 'execute',
requires_state_change: false,
requires_submission: false,
read_scope: 'visible_page',
summary: 'Find the download link for the current report',
steps: [{ id: '1', action: 'Read the page and return the download URL' }],
risks: [],
}), { requireIntent: true, locale: 'en' });
assert.equal(linkLookup?.requires_state_change, false, `${label}: read-only download-link lookup was over-classified`);

const toolDeclaredDownload = parse(JSON.stringify({
request_kind: 'execute',
requires_state_change: false,
requires_submission: false,
read_scope: 'visible_page',
summary: 'Save the selected media locally',
steps: [{ id: '1', action: 'Save the selected media', tools: ['download_public_media'] }],
risks: [],
}), { requireIntent: true, locale: 'en' });
assert.equal(toolDeclaredDownload?.requires_state_change, true, `${label}: declared download tool stayed read-only`);

for (const action of [
'Save the selected media locally',
'Store the video on the device',
'Saving the report to the Downloads folder',
'Storing the recording to disk',
]) {
const localSave = parse(JSON.stringify({
request_kind: 'execute',
requires_state_change: false,
requires_submission: false,
read_scope: 'visible_page',
summary: action,
steps: [{ id: '1', action }],
risks: [],
}), { requireIntent: true, locale: 'en' });
assert.equal(localSave?.requires_state_change, true, `${label}: compact local-save action stayed read-only: ${action}`);
}
}
});

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);
Expand Down
Loading