-
Proposed browser plan
+
+
Proposed browser plan
${steps.map((label, i) => `
-
+
${i + 1}
+ background:var(--accent); color:${card.onAccent}; font-size:17px; font-weight:850;">${i + 1}
${label}
`).join('')}
Approve
+ border-radius:14px; background:var(--accent); color:${card.onAccent}; font-size:17px; font-weight:800;">Approve
Adjust
+ border-radius:14px; border:1px solid ${light ? 'var(--border)' : 'rgba(255,255,255,0.2)'};
+ background:${light ? '#f4f6fa' : 'rgba(255,255,255,0.07)'};
+ color:${light ? 'var(--ink)' : '#e9eef6'}; font-size:17px; font-weight:800;">Adjust
@@ -393,7 +406,10 @@ function proofScene() {
};
}
-const scenes = [hero(), actScene(), askScene(), modelsScene(), planScene(), offerScene(), proofScene()];
+const scenes = [
+ hero(), actScene(), askScene(), modelsScene(), planScene(), offerScene(), proofScene(),
+ hero(true), planScene(true),
+];
function html(scene) {
return `
diff --git a/assets/webstore-explainer-2026-3/01-hero-light.png b/assets/webstore-explainer-2026-3/01-hero-light.png
new file mode 100644
index 000000000..52bec7896
Binary files /dev/null and b/assets/webstore-explainer-2026-3/01-hero-light.png differ
diff --git a/assets/webstore-explainer-2026-3/01-hero.png b/assets/webstore-explainer-2026-3/01-hero.png
new file mode 100644
index 000000000..49b0dca38
Binary files /dev/null and b/assets/webstore-explainer-2026-3/01-hero.png differ
diff --git a/assets/webstore-explainer-2026-3/02-tell-the-browser.png b/assets/webstore-explainer-2026-3/02-tell-the-browser.png
new file mode 100644
index 000000000..0ec127a16
Binary files /dev/null and b/assets/webstore-explainer-2026-3/02-tell-the-browser.png differ
diff --git a/assets/webstore-explainer-2026-3/03-ask-any-page.png b/assets/webstore-explainer-2026-3/03-ask-any-page.png
new file mode 100644
index 000000000..261ae7f25
Binary files /dev/null and b/assets/webstore-explainer-2026-3/03-ask-any-page.png differ
diff --git a/assets/webstore-explainer-2026-3/04-any-llm.png b/assets/webstore-explainer-2026-3/04-any-llm.png
new file mode 100644
index 000000000..e743b6761
Binary files /dev/null and b/assets/webstore-explainer-2026-3/04-any-llm.png differ
diff --git a/assets/webstore-explainer-2026-3/05-plan-before-act-light.png b/assets/webstore-explainer-2026-3/05-plan-before-act-light.png
new file mode 100644
index 000000000..84e24d00f
Binary files /dev/null and b/assets/webstore-explainer-2026-3/05-plan-before-act-light.png differ
diff --git a/assets/webstore-explainer-2026-3/05-plan-before-act.png b/assets/webstore-explainer-2026-3/05-plan-before-act.png
new file mode 100644
index 000000000..26b2ba2ac
Binary files /dev/null and b/assets/webstore-explainer-2026-3/05-plan-before-act.png differ
diff --git a/assets/webstore-explainer-2026-3/06-launch-offer.png b/assets/webstore-explainer-2026-3/06-launch-offer.png
new file mode 100644
index 000000000..f2d6e88c2
Binary files /dev/null and b/assets/webstore-explainer-2026-3/06-launch-offer.png differ
diff --git a/assets/webstore-explainer-2026-3/07-social-proof.png b/assets/webstore-explainer-2026-3/07-social-proof.png
new file mode 100644
index 000000000..19f4baf0b
Binary files /dev/null and b/assets/webstore-explainer-2026-3/07-social-proof.png differ
diff --git a/assets/webstore-explainer-2026-3/README.md b/assets/webstore-explainer-2026-3/README.md
new file mode 100644
index 000000000..e4c1249b6
--- /dev/null
+++ b/assets/webstore-explainer-2026-3/README.md
@@ -0,0 +1,39 @@
+# Web Store explainer visuals 2026 — v3 (logo-free)
+
+Same gallery as v2 (`../webstore-explainer-2026-2`), with the WebBrain product icon
+removed from every slide — the Web Store already shows it beside the listing. The
+"WebBrain" wordmark stays as text in the corner brand row and the hero.
+
+Dropped in four places vs v2: the hero icon, the `.brand` row icon, the chat-input
+avatar on 02, and the now-unused `assets.logo` data URI. The GitHub mark on 07 is
+not ours and stays.
+
+v2 remains the version to edit if you want the icon back; the two are independent
+copies, so a content change needs applying in both.
+
+Files (1280×800):
+- 01-hero.png: Wordmark + tagline hero
+- 02-tell-the-browser.png: Flight-search command front and center as chat input, browser acting on it
+- 03-ask-any-page.png: Ask mode, cropped to the answer panel
+- 04-any-llm.png: Model picker, cropped to the provider dropdown
+- 05-plan-before-act.png: Plan review with Approve/Adjust before actions run
+- 06-launch-offer.png: WebBrain Cloud $5/mo (reg. $8), Save 35%
+- 07-social-proof.png: 500+ GitHub stars, 20+ contributors, MIT — repo bar with contributor avatars
+
+Light-background alternates of the two dark slides (originals kept, use whichever fits the gallery):
+- 01-hero-light.png
+- 05-plan-before-act-light.png
+
+Both come from the same `hero(light)` / `planScene(light)` functions, so edits apply to
+dark and light together. In the light plan card the panel goes white and the accent green
+darkens for contrast against white button text.
+
+Star/contributor counts are hardcoded in `proofScene()`; bump them there when they go stale (544 stars at last render, 2026-07-29). The avatar initials are decorative, not real contributor handles.
+
+Slide 07 is appended after the offer to keep the existing filenames stable. If proof-then-price reads better, swap the last two entries in the `scenes` array and rename the two PNGs.
+
+Regenerate:
+
+```bash
+node assets/webstore-explainer-2026-3/render.mjs
+```
diff --git a/assets/webstore-explainer-2026-3/render.mjs b/assets/webstore-explainer-2026-3/render.mjs
new file mode 100644
index 000000000..f02bdd2d0
--- /dev/null
+++ b/assets/webstore-explainer-2026-3/render.mjs
@@ -0,0 +1,435 @@
+import { chromium } from 'playwright';
+import { readFileSync } from 'node:fs';
+import { mkdir } from 'node:fs/promises';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const DIR = path.dirname(fileURLToPath(import.meta.url));
+const ROOT = path.resolve(DIR, '../..');
+
+// No product logo in this variant — the Web Store already shows the icon beside the listing.
+const assets = {
+ ask: dataUri('assets/screenshot-1-ask-mode.png'),
+ models: dataUri('assets/screenshots_v1/s7-model-selector.png'),
+};
+
+function dataUri(relativePath) {
+ const bytes = readFileSync(path.join(ROOT, relativePath));
+ return `data:image/png;base64,${bytes.toString('base64')}`;
+}
+
+const W = 1280;
+const H = 800;
+
+const baseCss = `
+ :root { color-scheme: light; }
+ * { box-sizing: border-box; }
+ html, body {
+ margin: 0; width: ${W}px; height: ${H}px; overflow: hidden;
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
+ }
+ .canvas {
+ position: relative; width: ${W}px; height: ${H}px; overflow: hidden;
+ background: linear-gradient(135deg, var(--bg1), var(--bg2));
+ color: var(--ink); isolation: isolate;
+ }
+ .canvas:after {
+ content: ""; position: absolute; z-index: 0; pointer-events: none;
+ right: -120px; bottom: -140px; width: 540px; height: 360px;
+ background:
+ linear-gradient(90deg, transparent 0 28px, rgba(255,255,255,0.14) 28px 30px, transparent 30px 72px),
+ linear-gradient(0deg, transparent 0 28px, rgba(255,255,255,0.12) 28px 30px, transparent 30px 72px);
+ transform: rotate(-8deg); opacity: 0.8;
+ }
+ .dark { --bg1:#121525; --bg2:#243044; --ink:#ffffff; --muted:#d5d9e3;
+ --accent:#f56ca8; --accent2:#65d69d; --border:rgba(255,255,255,0.16);
+ --panel:rgba(255,255,255,0.08); --shadow:0 28px 70px rgba(0,0,0,0.32); }
+ .act { --bg1:#f7fbff; --bg2:#fff4ef; --ink:#182033; --muted:#596473;
+ --accent:#df573f; --accent2:#4d7df5; --border:rgba(31,40,64,0.14);
+ --panel:rgba(255,255,255,0.9); --shadow:0 28px 70px rgba(68,50,40,0.20); }
+ .read { --bg1:#fffaf3; --bg2:#eef5ff; --ink:#171827; --muted:#565e70;
+ --accent:#6757ff; --accent2:#ff6f9d; --border:rgba(37,42,66,0.12);
+ --panel:rgba(255,255,255,0.88); --shadow:0 28px 70px rgba(31,35,62,0.20); }
+ .plan { --bg1:#111827; --bg2:#223040; --ink:#f8fafc; --muted:#c7d2de;
+ --accent:#45d483; --accent2:#ffc857; --border:rgba(255,255,255,0.18);
+ --panel:rgba(255,255,255,0.08); --shadow:0 28px 80px rgba(0,0,0,0.34); }
+ .provider { --bg1:#f6f8fb; --bg2:#eef8f1; --ink:#182033; --muted:#5b6574;
+ --accent:#3e6ff4; --accent2:#28a96b; --border:rgba(25,38,68,0.13);
+ --panel:rgba(255,255,255,0.9); --shadow:0 28px 70px rgba(24,52,90,0.19); }
+ .hero-light { --bg1:#f7f9ff; --bg2:#fff1f6; --ink:#141828; --muted:#586074;
+ --accent:#d6417f; --accent2:#12a06a; --border:rgba(28,34,64,0.13);
+ --panel:rgba(255,255,255,0.9); --shadow:0 28px 70px rgba(40,44,80,0.18); }
+ .plan-light { --bg1:#f5fbf7; --bg2:#eef4ff; --ink:#141b26; --muted:#586374;
+ --accent:#12a25f; --accent2:#dd9414; --border:rgba(24,38,48,0.13);
+ --panel:rgba(255,255,255,0.9); --shadow:0 28px 70px rgba(28,50,44,0.18); }
+ .proof { --bg1:#f7f6ff; --bg2:#eef4ff; --ink:#171a2b; --muted:#5a6274;
+ --accent:#6e56cf; --accent2:#f0a52b; --border:rgba(30,34,64,0.13);
+ --panel:rgba(255,255,255,0.9); --shadow:0 28px 70px rgba(38,34,78,0.18); }
+ .content { position: relative; z-index: 1; width: 100%; height: 100%; padding: 44px 56px; }
+ .brand { display: flex; align-items: center; gap: 13px; font-size: 22px; font-weight: 760; }
+ .eyebrow {
+ display: inline-flex; align-items: center; gap: 10px; padding: 9px 14px;
+ color: var(--accent); background: var(--panel); border: 1px solid var(--border);
+ border-radius: 999px; font-size: 14px; font-weight: 780; text-transform: uppercase;
+ letter-spacing: 0.06em; line-height: 1;
+ }
+ .eyebrow .dot { width: 9px; height: 9px; border-radius: 999px; background: var(--accent2);
+ box-shadow: 0 0 0 5px color-mix(in srgb, var(--accent2) 18%, transparent); }
+ h1 { margin: 18px 0 0; font-size: 60px; line-height: 1.04; font-weight: 830; text-wrap: balance; }
+ .sub { margin-top: 14px; color: var(--muted); font-size: 24px; font-weight: 540; }
+ .crop-frame {
+ overflow: hidden; border: 1px solid var(--border); border-radius: 24px;
+ background: #ffffff; box-shadow: var(--shadow); position: relative;
+ }
+ .crop-frame img { display: block; }
+`;
+
+function brandRow() {
+ return `
+
+ WebBrain
+
`;
+}
+
+/* ---------- 01 HERO (dark + light variant) ---------- */
+function hero(light = false) {
+ return {
+ file: light ? '01-hero-light.png' : '01-hero.png',
+ theme: light ? 'hero-light' : 'dark',
+ body: `
+
+
WebBrain
+
Your open-source
AI browser agent
+
+ Ask.
+ Act.
+ Automate. Any LLM.
+
+
+ ${['Chromium browsers and Firefox', 'Local or cloud models', 'MIT licensed'].map((c) => `
+ ${c}`).join('')}
+
+
`,
+ };
+}
+
+/* ---------- 02 TELL THE BROWSER (command front and center) ---------- */
+function actScene() {
+ const steps = [
+ ['done', 'Found the flight search form'],
+ ['done', 'Typed “Istanbul (IST)”'],
+ ['done', 'Typed “San Francisco (SFO)”'],
+ ['live', 'Clicking Search…'],
+ ];
+ return {
+ file: '02-tell-the-browser.png',
+ theme: 'act',
+ body: `
+
+ ${brandRow()}
+ Act mode
+
+
+
Tell the browser what to do.
+
+
+
+
+
+ Search for the cheapest flights from Istanbul to San Francisco
+
+
↑
+
+
+
+
+
+
+
+
+
+ google.com/travel/flights
+
+
+
Google Flights
+
+
Istanbul (IST)
+
→
+
San Francisco (SFO)
+
+
+
+
+
+ $612
+
+
+
+ $688
+
+
+
+
+
+
WebBrain is acting
+
+ ${steps.map(([state, label]) => `
+
+ ${state === 'live' ? '●' : '✓'}
+ ${label}
+
`).join('')}
+
+
+ Pauses before anything irreversible
+
+
+
`,
+ };
+}
+
+/* ---------- 03 ASK ANY PAGE (cropped to the answer panel) ---------- */
+function askScene() {
+ // Source 1280x800. Relevant UI: sidebar below header x 905-1280, y 122-474. Scale 1.62.
+ const s = 1.62;
+ const x = 906, y = 122, w = 374, h = 352;
+ return {
+ file: '03-ask-any-page.png',
+ theme: 'read',
+ body: `
+
+ ${brandRow()}
+ Ask mode
+
+
+
+
Ask any page.
Get the useful part.
+
Clean answers from messy pages. Read-only by default.
+
+
+
+

+
+
+
`,
+ };
+}
+
+/* ---------- 04 ANY LLM (cropped to model dropdown) ---------- */
+function modelsScene() {
+ // Source 1280x800. Relevant UI: dropdown+header x 418-1162, y 6-748. Scale 0.82.
+ const s = 0.82;
+ const x = 418, y = 6, w = 744, h = 742;
+ return {
+ file: '04-any-llm.png',
+ theme: 'provider',
+ body: `
+
+ ${brandRow()}
+ Any LLM
+
+
+
+
Use the model you trust.
+
Local, cloud, or your own keys — switch anytime.
+
+
+
+

+
+
+
`,
+ };
+}
+
+/* ---------- 05 PLAN BEFORE ACT (dark + light variant) ---------- */
+function planScene(light = false) {
+ const steps = [
+ 'Read the visible form and required fields',
+ 'Fill only what you asked for',
+ 'Pause before any purchase or submit',
+ ];
+ const card = light
+ ? { bg: '#ffffff', border: 'var(--border)', shadow: '0 30px 70px rgba(28,50,44,0.18)',
+ label: 'var(--muted)', step: 'var(--ink)', onAccent: '#ffffff' }
+ : { bg: '#151c2a', border: 'rgba(255,255,255,0.16)', shadow: '0 30px 80px rgba(0,0,0,0.42)',
+ label: '#8d99ad', step: '#d7e2ea', onAccent: '#102319' };
+ return {
+ file: light ? '05-plan-before-act-light.png' : '05-plan-before-act.png',
+ theme: light ? 'plan-light' : 'plan',
+ body: `
+
+ ${brandRow()}
+ Plan before Act
+
+
+
+
See the plan before it touches the page.
+
Approve first. You stay in the loop.
+
+
+
+
Proposed browser plan
+
+ ${steps.map((label, i) => `
+
+ ${i + 1}
+ ${label}
+
`).join('')}
+
+
+ Approve
+ Adjust
+
+
+
+
`,
+ };
+}
+
+/* ---------- 06 LAUNCH OFFER ---------- */
+function offerScene() {
+ return {
+ file: '06-launch-offer.png',
+ theme: 'dark',
+ body: `
+
+ ${brandRow()}
+ Launch offer
+
+
+
WebBrain Cloud launch pricing
+
+ $8
+ $5/mo
+ Save 35%
+
+
No setup, no API keys — just install and go.
+
Or free forever with your own keys or local models.
+
`,
+ };
+}
+
+/* ---------- 07 SOCIAL PROOF ---------- */
+function proofScene() {
+ const icon = (paths, color) =>
+ `
`;
+ const stats = [
+ [icon('
', 'var(--accent2)'),
+ '500+', 'GitHub stars'],
+ [icon('
', 'var(--accent)'),
+ '20+', 'contributors'],
+ [icon('
', 'var(--accent)'),
+ 'MIT', 'licensed, free forever'],
+ ];
+ // Decorative contributor avatars: initials over a fixed palette.
+ const avatars = [
+ ['ES', '#6e56cf'], ['MK', '#e0644f'], ['AY', '#2b9f6b'], ['JR', '#2f6fe0'],
+ ['LP', '#c8478f'], ['DT', '#e08b2b'], ['SN', '#4a5568'], ['KV', '#0f9aa8'],
+ ['BW', '#8b5cf6'], ['RO', '#d14f6d'],
+ ];
+ return {
+ file: '07-social-proof.png',
+ theme: 'proof',
+ body: `
+
+ ${brandRow()}
+ Open source
+
+
+
+
Built in the open.
+
+
+
+ ${stats.map(([glyph, value, label]) => `
+
+
${glyph}
+
${value}
+
${label}
+
`).join('')}
+
+
+
+
+
+
+
github.com/webbrain-one/webbrain
+
Star it, fork it, ship a PR.
+
+
+
+
+ ${avatars.map(([initials, bg], i) => `
+ ${initials}`).join('')}
+ +10
+
+
+ Star
+
+
`,
+ };
+}
+
+const scenes = [
+ hero(), actScene(), askScene(), modelsScene(), planScene(), offerScene(), proofScene(),
+ hero(true), planScene(true),
+];
+
+function html(scene) {
+ return `
+
+
${scene.body}
`;
+}
+
+async function renderAll() {
+ await mkdir(DIR, { recursive: true });
+ const browser = await chromium.launch();
+ for (const scene of scenes) {
+ const page = await browser.newPage({ viewport: { width: W, height: H }, deviceScaleFactor: 1 });
+ await page.setContent(html(scene), { waitUntil: 'load' });
+ await page.evaluate(async () => {
+ await Promise.all(Array.from(document.images).map((img) => img.complete ? undefined : new Promise((res, rej) => {
+ img.addEventListener('load', res, { once: true });
+ img.addEventListener('error', rej, { once: true });
+ })));
+ await document.fonts.ready;
+ });
+ await page.screenshot({ path: path.join(DIR, scene.file) });
+ await page.close();
+ console.log('rendered', scene.file);
+ }
+ await browser.close();
+}
+
+renderAll().catch((error) => { console.error(error); process.exit(1); });
diff --git a/src/chrome/src/agent/agent.js b/src/chrome/src/agent/agent.js
index 6e0d801f8..490bb1b14 100644
--- a/src/chrome/src/agent/agent.js
+++ b/src/chrome/src/agent/agent.js
@@ -10,7 +10,7 @@ import { detectProgressAction, formatLedgerRow, formatLedgerSummary, isBlockedLe
import { buildGithubStargazerProgressItems } from './observers/github-stargazers.js';
import { analyzeMastodonPage, mastodonHandoffInstruction, mastodonProgressGuard } from './observers/mastodon.js';
import { isProgressActionAllowed, isProgressIntentActive, normalizeProgressAction, normalizeProgressIntent } from './progress-intent.js';
-import { completionDoneBlock, completionPlainFinalBlock, consumeCompletionObservation, consumeCompletionObservationResult, createCompletionInvariantState, hasUnconsumedCompletionObservation, hasUnconsumedCompletionObservationResult, recordCompletionToolResult } from './completion-invariant.js';
+import { classifyCompletionForm, completionDoneBlock, completionPlainFinalBlock, consumeCompletionObservation, consumeCompletionObservationResult, createCompletionInvariantState, hasUnconsumedCompletionObservation, hasUnconsumedCompletionObservationResult, recordCompletionToolResult } from './completion-invariant.js';
import { cdpClient } from '../cdp/cdp-client.js';
import { getActiveAdapter, getFullPageCapturePolicy, UNIVERSAL_PREAMBLE } from './adapters.js';
import {
@@ -659,6 +659,7 @@ export class Agent extends LoopDetector {
label: boundedText(form?.label, 80),
relevant: !!form?.relevant,
utility: !!form?.utility,
+ utilityReason: boundedText(form?.utilityReason, 40),
editableCount: Number(form?.editableCount || 0),
submitCount: Number(form?.submitCount || 0),
}))
@@ -14973,18 +14974,36 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
return true;
} catch (e) { return false; }
}
+ const classifyForm = ${classifyCompletionForm.toString()};
const dialogs = Array.from(document.querySelectorAll('[role=dialog],[role=alertdialog],[aria-modal="true"],dialog[open]')).filter(visible);
const forms = Array.from(document.querySelectorAll('form')).filter(visible);
+ const primaryContent = document.querySelector('main,[role=main]');
const formDescriptors = forms.map(form => {
const editable = Array.from(form.querySelectorAll('input:not([type=hidden]):not([type=button]):not([type=submit]):not([type=reset]):not([type=image]),textarea,select,[contenteditable=true]')).filter(visible);
const submits = Array.from(form.querySelectorAll('button,input[type=submit],input[type=image],[role=button]')).filter(visible);
const utilityRegion = !!form.closest('header,nav,footer,[role=banner],[role=navigation],[role=search],[role=contentinfo]') || form.getAttribute('role') === 'search';
- const searchOnly = editable.length > 0
- && editable.every(el => el.matches('input[type=search]') || /^(q|query|search|filter)$/i.test(el.getAttribute('name') || ''))
- && submits.every(el => /search|filter|go/i.test((el.innerText || el.value || el.getAttribute('aria-label') || '').trim()));
const label = (form.getAttribute('aria-label') || form.getAttribute('name') || form.id || (form.querySelector('h1,h2,h3,legend')?.innerText || '')).trim().slice(0, 80);
- const relevant = !utilityRegion && !searchOnly && (editable.length > 0 || submits.length > 0);
- return { label, relevant, utility: utilityRegion || searchOnly, editableCount: editable.length, submitCount: submits.length };
+ return classifyForm({
+ label,
+ utilityRegion,
+ outsidePrimaryContent: !!primaryContent && !form.closest('main,[role=main]'),
+ insideDialog: !!form.closest('[role=dialog],[role=alertdialog],[aria-modal=true],dialog[open]'),
+ method: form.getAttribute('method') || 'get',
+ hiddenNamedCount: Array.from(form.querySelectorAll('input[type=hidden][name]'))
+ .filter(el => (el.getAttribute('name') || '').trim()).length,
+ editable: editable.map(el => ({
+ tag: (el.tagName || '').toLowerCase(),
+ type: el.getAttribute('type') || '',
+ role: el.getAttribute('role') || '',
+ name: el.getAttribute('name') || '',
+ value: 'value' in el ? el.value : (el.innerText || ''),
+ required: el.required === true || el.getAttribute('aria-required') === 'true',
+ focused: document.activeElement === el,
+ })),
+ submits: submits.map(el => ({
+ label: (el.innerText || el.value || el.getAttribute('aria-label') || '').trim(),
+ })),
+ });
});
const toasts = Array.from(document.querySelectorAll('[role=status],[role=alert],[aria-live]'))
.filter(visible)
diff --git a/src/chrome/src/agent/completion-invariant.js b/src/chrome/src/agent/completion-invariant.js
index 6e1da8ef9..83ab4ea71 100644
--- a/src/chrome/src/agent/completion-invariant.js
+++ b/src/chrome/src/agent/completion-invariant.js
@@ -94,6 +94,101 @@ function isSelfVerifyingActionResult(name, result) {
);
}
+/**
+ * Classify one visible form from browser-neutral structural facts.
+ *
+ * Completion probes run inside the page, so Agent serializes this function
+ * with toString() and supplies plain facts rather than DOM nodes. Keep this
+ * function self-contained and language-independent for structural fallbacks.
+ */
+export function classifyCompletionForm({
+ label = '',
+ utilityRegion = false,
+ outsidePrimaryContent = false,
+ insideDialog = false,
+ method = 'get',
+ hiddenNamedCount = 0,
+ editable = [],
+ submits = [],
+} = {}) {
+ const fields = Array.isArray(editable) ? editable : [];
+ const submitControls = Array.isArray(submits) ? submits : [];
+ const normalizedMethod = String(method || 'get').trim().toLowerCase() || 'get';
+ const normalizedFields = fields.map(field => ({
+ tag: String(field?.tag || '').trim().toLowerCase(),
+ type: String(field?.type || '').trim().toLowerCase(),
+ role: String(field?.role || '').trim().toLowerCase(),
+ name: String(field?.name || '').trim(),
+ value: String(field?.value || '').trim(),
+ required: field?.required === true,
+ focused: field?.focused === true,
+ }));
+ const normalizedSubmits = submitControls.map(control => ({
+ label: String(control?.label || '').trim(),
+ }));
+
+ const semanticSearchField = field => field.type === 'search' || field.role === 'searchbox';
+ const conventionalSearchField = field => /^(q|query|search|filter)$/i.test(field.name);
+ // Preserve the previous per-field semantic-or-conventional behavior so a
+ // search input plus a named filter control remains utility UI. Task evidence
+ // gates the whole shortcut: semantic markup alone must not hide an assignee
+ // picker, dialog, POST form, or non-search submission. A retained value is
+ // normal search-results state and is not task evidence by itself.
+ const searchFieldsOnly = normalizedFields.length > 0
+ && normalizedFields.every(field => semanticSearchField(field) || conventionalSearchField(field));
+ const searchSubmitsOnly = normalizedSubmits.every(control => /search|filter|go/i.test(control.label));
+ const searchHasTaskEvidence = !!(
+ insideDialog
+ || normalizedMethod !== 'get'
+ || Number(hiddenNamedCount || 0) !== 0
+ || !searchSubmitsOnly
+ || normalizedFields.some(field => (
+ field.required
+ || field.focused
+ || (!!field.name && !conventionalSearchField(field))
+ ))
+ );
+ // Primary content alone is not task evidence: result pages commonly place
+ // genuine search/filter forms there.
+ const safeSearchOnly = searchFieldsOnly && !searchHasTaskEvidence;
+ const hasSemanticSearchField = normalizedFields.some(semanticSearchField);
+
+ const onlyField = normalizedFields.length === 1 ? normalizedFields[0] : null;
+ const passiveUtilityShell = !!(
+ onlyField
+ && outsidePrimaryContent
+ && !insideDialog
+ && normalizedMethod === 'get'
+ && Number(hiddenNamedCount || 0) === 0
+ && normalizedSubmits.length === 0
+ && onlyField.tag === 'input'
+ && (onlyField.type === '' || onlyField.type === 'text' || onlyField.type === 'search')
+ && !onlyField.name
+ && !onlyField.value
+ && !onlyField.required
+ && !onlyField.focused
+ );
+
+ const utilityReason = utilityRegion
+ ? 'utility_region'
+ : safeSearchOnly
+ ? hasSemanticSearchField
+ ? 'semantic_search'
+ : 'conventional_search'
+ : passiveUtilityShell
+ ? 'passive_utility_shell'
+ : null;
+ const utility = utilityReason !== null;
+ return {
+ label: String(label || '').trim().slice(0, 80),
+ relevant: !utility && (normalizedFields.length > 0 || normalizedSubmits.length > 0),
+ utility,
+ utilityReason,
+ editableCount: normalizedFields.length,
+ submitCount: normalizedSubmits.length,
+ };
+}
+
export function isCompletionActionTool(name, args = {}) {
if (name === 'execute_webmcp_tool') return true;
if (
diff --git a/src/firefox/src/agent/agent.js b/src/firefox/src/agent/agent.js
index b3c998377..0a67c0081 100644
--- a/src/firefox/src/agent/agent.js
+++ b/src/firefox/src/agent/agent.js
@@ -10,7 +10,7 @@ import { detectProgressAction, formatLedgerRow, formatLedgerSummary, isBlockedLe
import { buildGithubStargazerProgressItems } from './observers/github-stargazers.js';
import { analyzeMastodonPage, mastodonHandoffInstruction, mastodonProgressGuard } from './observers/mastodon.js';
import { isProgressActionAllowed, isProgressIntentActive, normalizeProgressAction, normalizeProgressIntent } from './progress-intent.js';
-import { completionDoneBlock, completionPlainFinalBlock, consumeCompletionObservation, consumeCompletionObservationResult, createCompletionInvariantState, hasUnconsumedCompletionObservation, hasUnconsumedCompletionObservationResult, recordCompletionToolResult } from './completion-invariant.js';
+import { classifyCompletionForm, completionDoneBlock, completionPlainFinalBlock, consumeCompletionObservation, consumeCompletionObservationResult, createCompletionInvariantState, hasUnconsumedCompletionObservation, hasUnconsumedCompletionObservationResult, recordCompletionToolResult } from './completion-invariant.js';
import { getActiveAdapter, UNIVERSAL_PREAMBLE } from './adapters.js';
import {
fetchUrl,
@@ -594,6 +594,7 @@ export class Agent extends LoopDetector {
label: boundedText(form?.label, 80),
relevant: !!form?.relevant,
utility: !!form?.utility,
+ utilityReason: boundedText(form?.utilityReason, 40),
editableCount: Number(form?.editableCount || 0),
submitCount: Number(form?.submitCount || 0),
}))
@@ -12841,18 +12842,36 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
return true;
} catch (e) { return false; }
}
+ const classifyForm = ${classifyCompletionForm.toString()};
const dialogs = Array.from(document.querySelectorAll('[role=dialog],[role=alertdialog],[aria-modal="true"],dialog[open]')).filter(visible);
const forms = Array.from(document.querySelectorAll('form')).filter(visible);
+ const primaryContent = document.querySelector('main,[role=main]');
const formDescriptors = forms.map(form => {
const editable = Array.from(form.querySelectorAll('input:not([type=hidden]):not([type=button]):not([type=submit]):not([type=reset]):not([type=image]),textarea,select,[contenteditable=true]')).filter(visible);
const submits = Array.from(form.querySelectorAll('button,input[type=submit],input[type=image],[role=button]')).filter(visible);
const utilityRegion = !!form.closest('header,nav,footer,[role=banner],[role=navigation],[role=search],[role=contentinfo]') || form.getAttribute('role') === 'search';
- const searchOnly = editable.length > 0
- && editable.every(el => el.matches('input[type=search]') || /^(q|query|search|filter)$/i.test(el.getAttribute('name') || ''))
- && submits.every(el => /search|filter|go/i.test((el.innerText || el.value || el.getAttribute('aria-label') || '').trim()));
const label = (form.getAttribute('aria-label') || form.getAttribute('name') || form.id || (form.querySelector('h1,h2,h3,legend')?.innerText || '')).trim().slice(0, 80);
- const relevant = !utilityRegion && !searchOnly && (editable.length > 0 || submits.length > 0);
- return { label, relevant, utility: utilityRegion || searchOnly, editableCount: editable.length, submitCount: submits.length };
+ return classifyForm({
+ label,
+ utilityRegion,
+ outsidePrimaryContent: !!primaryContent && !form.closest('main,[role=main]'),
+ insideDialog: !!form.closest('[role=dialog],[role=alertdialog],[aria-modal=true],dialog[open]'),
+ method: form.getAttribute('method') || 'get',
+ hiddenNamedCount: Array.from(form.querySelectorAll('input[type=hidden][name]'))
+ .filter(el => (el.getAttribute('name') || '').trim()).length,
+ editable: editable.map(el => ({
+ tag: (el.tagName || '').toLowerCase(),
+ type: el.getAttribute('type') || '',
+ role: el.getAttribute('role') || '',
+ name: el.getAttribute('name') || '',
+ value: 'value' in el ? el.value : (el.innerText || ''),
+ required: el.required === true || el.getAttribute('aria-required') === 'true',
+ focused: document.activeElement === el,
+ })),
+ submits: submits.map(el => ({
+ label: (el.innerText || el.value || el.getAttribute('aria-label') || '').trim(),
+ })),
+ });
});
const toasts = Array.from(document.querySelectorAll('[role=status],[role=alert],[aria-live]'))
.filter(visible)
diff --git a/src/firefox/src/agent/completion-invariant.js b/src/firefox/src/agent/completion-invariant.js
index 6e1da8ef9..83ab4ea71 100644
--- a/src/firefox/src/agent/completion-invariant.js
+++ b/src/firefox/src/agent/completion-invariant.js
@@ -94,6 +94,101 @@ function isSelfVerifyingActionResult(name, result) {
);
}
+/**
+ * Classify one visible form from browser-neutral structural facts.
+ *
+ * Completion probes run inside the page, so Agent serializes this function
+ * with toString() and supplies plain facts rather than DOM nodes. Keep this
+ * function self-contained and language-independent for structural fallbacks.
+ */
+export function classifyCompletionForm({
+ label = '',
+ utilityRegion = false,
+ outsidePrimaryContent = false,
+ insideDialog = false,
+ method = 'get',
+ hiddenNamedCount = 0,
+ editable = [],
+ submits = [],
+} = {}) {
+ const fields = Array.isArray(editable) ? editable : [];
+ const submitControls = Array.isArray(submits) ? submits : [];
+ const normalizedMethod = String(method || 'get').trim().toLowerCase() || 'get';
+ const normalizedFields = fields.map(field => ({
+ tag: String(field?.tag || '').trim().toLowerCase(),
+ type: String(field?.type || '').trim().toLowerCase(),
+ role: String(field?.role || '').trim().toLowerCase(),
+ name: String(field?.name || '').trim(),
+ value: String(field?.value || '').trim(),
+ required: field?.required === true,
+ focused: field?.focused === true,
+ }));
+ const normalizedSubmits = submitControls.map(control => ({
+ label: String(control?.label || '').trim(),
+ }));
+
+ const semanticSearchField = field => field.type === 'search' || field.role === 'searchbox';
+ const conventionalSearchField = field => /^(q|query|search|filter)$/i.test(field.name);
+ // Preserve the previous per-field semantic-or-conventional behavior so a
+ // search input plus a named filter control remains utility UI. Task evidence
+ // gates the whole shortcut: semantic markup alone must not hide an assignee
+ // picker, dialog, POST form, or non-search submission. A retained value is
+ // normal search-results state and is not task evidence by itself.
+ const searchFieldsOnly = normalizedFields.length > 0
+ && normalizedFields.every(field => semanticSearchField(field) || conventionalSearchField(field));
+ const searchSubmitsOnly = normalizedSubmits.every(control => /search|filter|go/i.test(control.label));
+ const searchHasTaskEvidence = !!(
+ insideDialog
+ || normalizedMethod !== 'get'
+ || Number(hiddenNamedCount || 0) !== 0
+ || !searchSubmitsOnly
+ || normalizedFields.some(field => (
+ field.required
+ || field.focused
+ || (!!field.name && !conventionalSearchField(field))
+ ))
+ );
+ // Primary content alone is not task evidence: result pages commonly place
+ // genuine search/filter forms there.
+ const safeSearchOnly = searchFieldsOnly && !searchHasTaskEvidence;
+ const hasSemanticSearchField = normalizedFields.some(semanticSearchField);
+
+ const onlyField = normalizedFields.length === 1 ? normalizedFields[0] : null;
+ const passiveUtilityShell = !!(
+ onlyField
+ && outsidePrimaryContent
+ && !insideDialog
+ && normalizedMethod === 'get'
+ && Number(hiddenNamedCount || 0) === 0
+ && normalizedSubmits.length === 0
+ && onlyField.tag === 'input'
+ && (onlyField.type === '' || onlyField.type === 'text' || onlyField.type === 'search')
+ && !onlyField.name
+ && !onlyField.value
+ && !onlyField.required
+ && !onlyField.focused
+ );
+
+ const utilityReason = utilityRegion
+ ? 'utility_region'
+ : safeSearchOnly
+ ? hasSemanticSearchField
+ ? 'semantic_search'
+ : 'conventional_search'
+ : passiveUtilityShell
+ ? 'passive_utility_shell'
+ : null;
+ const utility = utilityReason !== null;
+ return {
+ label: String(label || '').trim().slice(0, 80),
+ relevant: !utility && (normalizedFields.length > 0 || normalizedSubmits.length > 0),
+ utility,
+ utilityReason,
+ editableCount: normalizedFields.length,
+ submitCount: normalizedSubmits.length,
+ };
+}
+
export function isCompletionActionTool(name, args = {}) {
if (name === 'execute_webmcp_tool') return true;
if (
diff --git a/test/run.js b/test/run.js
index 7dc1cd55b..a86911a84 100644
--- a/test/run.js
+++ b/test/run.js
@@ -10108,6 +10108,157 @@ test('getToolsForMode: `done` outcome is required in every supported Act and Dev
}
});
+test('completion form classification ignores passive localized utility shells without weakening real form guards', () => {
+ const passiveSidebarSearch = {
+ label: '',
+ outsidePrimaryContent: true,
+ insideDialog: false,
+ method: 'get',
+ hiddenNamedCount: 0,
+ editable: [{
+ tag: 'input',
+ type: 'text',
+ role: '',
+ name: '',
+ value: '',
+ required: false,
+ focused: false,
+ }],
+ submits: [],
+ };
+
+ for (const [label, invariant, agentRel] of [
+ ['chrome', CompletionInvariantCh, 'src/chrome/src/agent/agent.js'],
+ ['firefox', CompletionInvariantFx, 'src/firefox/src/agent/agent.js'],
+ ]) {
+ const classify = invariant.classifyCompletionForm;
+ const reconstructed = Function(`return (${classify.toString()});`)();
+ assert.deepEqual(
+ reconstructed(passiveSidebarSearch),
+ classify(passiveSidebarSearch),
+ `${label}: completion form classifier is not self-contained for page injection`,
+ );
+ assert.deepEqual(
+ classify(passiveSidebarSearch),
+ {
+ label: '',
+ relevant: false,
+ utility: true,
+ utilityReason: 'passive_utility_shell',
+ editableCount: 1,
+ submitCount: 0,
+ },
+ `${label}: Mastodon-shaped localized sidebar search still blocked completion`,
+ );
+ assert.deepEqual(
+ classify({
+ editable: [{ tag: 'input', type: 'search', role: '', name: '', value: '' }],
+ submits: [],
+ }),
+ {
+ label: '',
+ relevant: false,
+ utility: true,
+ utilityReason: 'semantic_search',
+ editableCount: 1,
+ submitCount: 0,
+ },
+ `${label}: semantic search input without a submit control was not recognized structurally`,
+ );
+ assert.deepEqual(
+ classify({
+ outsidePrimaryContent: false,
+ editable: [
+ { tag: 'input', type: 'search', role: '', name: '', value: '' },
+ { tag: 'select', type: '', role: '', name: 'filter', value: '' },
+ ],
+ submits: [{ label: 'Filter' }],
+ }),
+ {
+ label: '',
+ relevant: false,
+ utility: true,
+ utilityReason: 'semantic_search',
+ editableCount: 2,
+ submitCount: 1,
+ },
+ `${label}: mixed semantic and conventional search fields regressed`,
+ );
+ assert.deepEqual(
+ classify({
+ outsidePrimaryContent: false,
+ editable: [
+ { tag: 'input', type: 'search', role: '', name: 'q', value: 'webbrain' },
+ { tag: 'select', type: '', role: '', name: 'filter', value: 'recent' },
+ ],
+ submits: [{ label: 'Search' }],
+ }),
+ {
+ label: '',
+ relevant: false,
+ utility: true,
+ utilityReason: 'semantic_search',
+ editableCount: 2,
+ submitCount: 1,
+ },
+ `${label}: retained search query and filter values were treated as task drafts`,
+ );
+
+ for (const [caseName, override] of [
+ ['primary-content form', { outsidePrimaryContent: false }],
+ ['dialog form', { insideDialog: true }],
+ ['POST form', { method: 'post' }],
+ ['form with hidden submitted state', { hiddenNamedCount: 1 }],
+ ['required field', { editable: [{ ...passiveSidebarSearch.editable[0], required: true }] }],
+ ['focused field', { editable: [{ ...passiveSidebarSearch.editable[0], focused: true }] }],
+ ['draft-bearing field', { editable: [{ ...passiveSidebarSearch.editable[0], value: 'draft' }] }],
+ ['named task field', { editable: [{ ...passiveSidebarSearch.editable[0], name: 'title' }] }],
+ ['form with a submit control', { submits: [{ label: 'Gönder' }] }],
+ ]) {
+ const result = classify({ ...passiveSidebarSearch, ...override });
+ assert.equal(result.relevant, true, `${label}: ${caseName} was hidden as utility UI`);
+ assert.equal(result.utility, false, `${label}: ${caseName} received a utility classification`);
+ }
+
+ const semanticTaskSelector = {
+ outsidePrimaryContent: true,
+ insideDialog: false,
+ method: 'get',
+ hiddenNamedCount: 0,
+ editable: [{
+ tag: 'input',
+ type: 'search',
+ role: 'searchbox',
+ name: '',
+ value: '',
+ required: false,
+ focused: false,
+ }],
+ submits: [],
+ };
+ for (const [caseName, override] of [
+ ['dialog search selector', { insideDialog: true }],
+ ['POST search selector', { method: 'post' }],
+ ['search selector with hidden submitted state', { hiddenNamedCount: 1 }],
+ ['required search selector', { editable: [{ ...semanticTaskSelector.editable[0], required: true }] }],
+ ['focused search selector', { editable: [{ ...semanticTaskSelector.editable[0], focused: true }] }],
+ ['named task search selector', { editable: [{ ...semanticTaskSelector.editable[0], name: 'assignee' }] }],
+ ['search selector with non-search submit', { submits: [{ label: 'Add' }] }],
+ ]) {
+ const result = classify({ ...semanticTaskSelector, ...override });
+ assert.equal(result.relevant, true, `${label}: ${caseName} was hidden as semantic search UI`);
+ assert.equal(result.utility, false, `${label}: ${caseName} received a semantic utility classification`);
+ }
+
+ const agentSource = fs.readFileSync(path.join(ROOT, agentRel), 'utf8');
+ assert.match(
+ agentSource,
+ /const classifyForm = \$\{classifyCompletionForm\.toString\(\)\}/,
+ `${label}: done verification probe is not wired to the shared form classifier`,
+ );
+ }
+});
+
test('completion invariant state machine enforces post-action observation with Chrome/Firefox parity', () => {
assert.equal(
fs.readFileSync(path.join(ROOT, 'src/chrome/src/agent/completion-invariant.js'), 'utf8'),