A diagnostic benchmark that decomposes web-agent competence into seven cognitive primitives and stresses each one through controlled environment interventions. Seven interactive environments, 519 base tasks, 530 intervention variants, deterministic scoring against server state.
Existing web-agent benchmarks score whole-task completion. A run either succeeds or fails, and a single scalar gets reported. The scalar is useful for ranking models, yet it carries almost no diagnostic information. When an agent finishes a 15-step checkout with score 0, the log does not say whether the agent misread a product card, accepted a forged "Saved" toast, abandoned a retry after a transient 503, or walked past the only remaining affordance. The benchmark does not know, and the training signal that follows does not know either.
What we actually want to measure is a set of cognitive primitives: the atomic capabilities a web user exercises across every domain. A competent user grounds an observation, plans sub-goals, tracks state, backtracks on a bad decision, waits out a flaky request, explores when the obvious path disappears, and verifies that an action took effect. A benchmark that exposes each primitive independently lets a developer read a model profile, identify the weakest axis, and target training at that axis. Whole-task benchmarks flatten this profile into a single number and obscure the gradient.
PrimBench addresses the gap through environment interventions. We take a well-formed base task, apply a controlled perturbation that stresses exactly one primitive, and grade the outcome against the same success criterion as the baseline. A drop between baseline and intervention isolates the failing primitive, because everything else in the environment is held fixed. The intervention is the experimental manipulation; the primitive is the dependent variable.
We settle on seven primitives. Two constraints guided the choice: the set should cover every failure mode reported in recent agent studies, and the primitives should not overlap semantically, so that a failure on a targeted intervention cannot be attributed to more than one primitive.
Map observation to the correct semantic understanding of the UI. Pick the real target when decoys, near-lookalikes, adversarial content, or mislabeled controls are present.
Decompose a goal into ordered sub-goals and respect dependencies between them. Keep the plan consistent as new information arrives.
Maintain a working model of what is done versus pending across a multi-step trajectory. Reconcile updates that arrive out of order.
Detect a failure, revert to a prior decision point, and try an alternative. Recognize that the current path is blocked rather than retry the same action.
Know when to wait. Calibrate retry timing through slow, flaky, or rate-limited operations, and distinguish a loading state from a failure.
Discover alternative affordances and paths when the obvious one fails. Try the non-default entry point rather than abandon the task.
After performing an action, check that it actually achieved its intended effect. Do not take a success banner at face value.
Each variant names exactly one target_primitive. The catalog enforces |task.primitives ∪ {variant.target_primitive}| ≤ 2, which caps confounding at the (task, variant) level. A failure on a verification-targeted variant therefore reflects verification, holding the grounding demand of the base task fixed. The table below maps each primitive to the primary action classes that stress it and shows that each action class targets a disjoint primitive.
| Primitive | Primary stressor class | Representative action |
|---|---|---|
| Grounding | Adversarial or near-identical content | inject_adversarial_content, add_confusing_decoys, label_input_misalignment |
| Planning | Disordered prerequisites | scramble_timestamps, hide_prerequisite |
| State Tracking | Divergent or fragmented state | split_information, add_contradictory_update, shuffle_positions |
| Backtracking | Hard failure requiring alternative | session_expiry, concurrent_modification, plant_wrong_answer |
| Patience | Latency or rate pressure | delay (tail_latency, progressive), rate_limit |
| Exploration | Closed default path | restrict_affordance_set, hide_in_non_obvious_location, intercepting_overlay |
| Verification | False positive feedback | silent_fail, misleading_success, save_drift, click_swallow |
An intervention is not a single mechanism. A phishing email and a 503 retry and a swallowed click are all interventions, but they live at different places in the web stack and fire at different times. Collapsing them into one hook would either paper over real mechanisms (DOM mocks of network failure miss real HTTP timing) or miss mechanisms entirely (a middleware has no view of DOM occlusion). We therefore split the perturbation space by where each action fires.
| Layer | Captures | Cannot be expressed by the others |
|---|---|---|
| Seed | Content semantics before the session starts (phishing, decoys, adversarial bodies). | A network hook cannot rewrite the initial dataset the SPA seeds from; a client hook comes too late. |
| Server | Structural properties of state (ordering, timestamps, hidden labels). | The SPA consumes server state at boot; client-level shuffling is visible to refresh and easy to undo. |
| Network | Real HTTP timing and status (503, 429, 401, silent 200). | Client code cannot forge a Retry-After header that survives a page refresh, and seed data cannot cause latency. |
| Client | Interaction fidelity at the DOM (swallowed clicks, label drift, typed-input corruption, overlays). | Network and server see write intent, not whether the click that produced it landed on the right element. |
GmailState whether the email is starred, so a silently-dropped write is caught regardless of what the DOM shows.
The full catalog binds base tasks, intervention variants, and primitive coverage across seven environments.
| Environment | Domain | Base Tasks | Variants | Adversarial-content surface |
|---|---|---|---|---|
| Gmail | 84 | 94 | Email body and sender display name | |
| Amazon | E-commerce | 70 | 70 | Product reviews and notifications |
| Social | 81 | 81 | Post body and top comment | |
| Robinhood | Finance | 71 | 71 | Notifications (security_alert type) |
| Booking.com | Travel | 78 | 78 | Property reviews |
| LMS | Education | 65 | 65 | Announcements and discussion posts |
| Patient Portal | Healthcare | 70 | 71 | Clinical messages |
| Total | 7 domains | 519 | 530 | — |
We group dispatch branches into intervention families: each family captures one stressor mechanism, and the branches inside it are environment-specific specialisations that impose the same cognitive load. A family is the right unit for reasoning about primitive coverage; the dispatcher split matters only at the implementation level.
| Layer | Families | Dispatch branches | Primary primitives targeted |
|---|---|---|---|
| Seed | 7 | 14 | grounding, state tracking, exploration, backtracking, verification |
| Server | 5 | 10 | planning, state tracking, grounding, exploration, verification |
| Network | 7 | 8 | patience, verification, backtracking, state tracking |
| Client | 10 | 16 | grounding, verification, exploration, backtracking, patience |
| Total | 29 | 48 | all seven primitives |
| Family | What it does | Dispatches | Primitives |
|---|---|---|---|
| Decoys & aliases | Inserts near-identical items or similarly-named entities into the target list to dilute the signal the agent must ground to. | add_confusing_decoys, alias_entities, increase_distractors, add_decoy_notifications, add_noise_orders, add_confusing_positions, add_confusing_stocks | grounding |
| Adversarial content | Plants hostile items (phishing, prompt injection, urgency, impersonation, authority appeal). Paired with negative checks that fire if the agent follows the injected instruction. | inject_adversarial_content, add_misleading_alert | grounding, verification |
| Split information | Distributes task-critical information across multiple items so the agent cannot resolve the task from any single one. | split_information | state tracking |
| Contradictory update | Inserts a newer item that contradicts an older one. The agent must reconcile to the newer value. | add_contradictory_update | state tracking |
| Content inflation | Pads the target body with realistic filler (threads, legal boilerplate, digests); the answer is preserved at a chosen position (early / middle / late / repeated_contradicted). | inflate_target_content | state tracking, exploration |
| Planted wrong answer | Places a plausible-but-incorrect answer first, so a greedy agent commits to it without continuing to search. | plant_wrong_answer | backtracking |
| Hidden target | Buries the target item in an atypical folder, archive, or tab. | hide_in_non_obvious_location | exploration |
| Family | What it does | Dispatches | Primitives |
|---|---|---|---|
| Timestamp scramble | Applies random offsets to timestamps within the scope. The apparent chronology no longer matches causal order. | scramble_timestamps, scramble_order_timestamps, scramble_notification_timestamps | planning |
| Ordering shuffle | Randomises list ordering so visual position is uninformative; the agent must re-derive ordering from semantic fields. | shuffle_contacts, shuffle_positions | state tracking |
| Distractor injection | Adds realistic-looking but irrelevant entries inline with real ones. | inject_distractor_emails, inject_distractor_notifications | grounding |
| Prerequisite hiding | Removes a label or list that the task description assumes already exists. The agent must create the prerequisite or find a workaround. | hide_prerequisite, hide_watchlist | exploration |
| Field corruption | Modifies a specific field to introduce an inconsistency the agent must notice and repair on readback. | corrupt_state | verification |
| Family | What it does | Dispatches | Primitives |
|---|---|---|---|
| Latency | Inserts delay before forwarding to the real handler. Behaviour modes: once, intermittent, progressive, tail_latency (piecewise-linear p50/p95/p99), correlated_window, write_only_slow. | delay | patience |
| Transient error | Returns 503 / 500 / 502 / 429 for the first N calls, then passes through. Sets Retry-After on 429. | error_then_success | backtracking, patience |
| Fabricated success | Returns 200 while the real write is silently dropped. Two variants: silent_fail (quiet body) and misleading_success (loud body with success:true, toast:"Saved"). | silent_fail, misleading_success | verification |
| Stale response | Returns outdated or empty body for the first N GETs. The agent must reconcile a read that does not reflect a prior write. | stale_data | state tracking |
| Optimistic conflict | Returns 409 Conflict with a latest_snapshot of the "newer" state. The agent must reload and reconcile before retrying. | concurrent_modification | backtracking |
| Rate limit | After burst_limit calls, returns 429 with Retry-After for the next cooldown_calls requests. Tests whether the agent reads structured error responses. | rate_limit | patience |
| Session expiry | After expire_after_calls, returns 401. Cleared by a request to reauth_path. | session_expiry | backtracking |
| Family | What it does | Dispatches | Primitives |
|---|---|---|---|
| Label misbinding | Shifts or rotates accessibility associations (aria-label, <label for>, visible text) so agents relying on DOM semantics pick the wrong element. | scramble_aria, swap_labels, label_input_misalignment | grounding |
| Decoy element | Clones a clickable element and strips its handler, inserted adjacent to the real control. | add_decoy | grounding |
| Hidden/restricted affordance | Hides the default entry point or disables all but one of a row's redundant controls (image / title / menu / primary button). | hide_affordance, restrict_affordance_set | exploration, backtracking |
| Deceptive banner | Injects a misleading alert banner above the page content. | false_banner | grounding, verification |
| Swallowed click | First N clicks on a matching selector are no-ops; subsequent clicks work normally. | click_swallow | verification, patience |
| Input perturbation | Corrupts what the user typed or selected before it leaves the browser: neighbour-swap on <select> / date / radio, character-level corruption, or single-field drift at submit. | adjacent_selection, input_corruption, save_drift | verification |
| Double-fire trap | A second click on a submit-style button within window_ms fires the action twice. | double_submit_trap | verification, patience |
| Intercepting overlay | Near-invisible overlay (opacity 0.02) swallows clicks over a region. Dismissed by Escape or an 18×18-px corner button. | intercepting_overlay | exploration, patience |
| Stuck loader | Loading skeleton on a specific route never resolves; only refresh or navigation clears it. | skeleton_never_resolves | backtracking, patience |
| Interrupting modal | Injects a cookie / newsletter / survey modal on the Nth navigation. Close control is deliberately small (12 px); Escape dismisses. | distractor_modal | grounding, patience |
Families are a conceptual grouping. The underlying 48 dispatch branches exist for two reasons: (1) some families have environment-specific specialisations that share a mechanism but read from different state collections (add_decoy_notifications and add_noise_orders both inject noise, but one writes to the notification list and the other to the orders list); (2) the delay family collapses six behaviour modes into a single dispatch. One infrastructure branch, set_feature_flag, is excluded from both counts: it toggles a window.__wabFeatureFlags boolean and imposes no cognitive load on its own.
Every primitive has at least one variant in every environment. This lets a user compute a primitive-by-environment score matrix from a single evaluation run.
| Gmail | Amazon | Robinhood | Booking | LMS | Portal | ||
|---|---|---|---|---|---|---|---|
| Grounding | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Planning | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| State Tracking | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Backtracking | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Patience | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Exploration | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Verification | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Deterministic | Every action takes a seed. The same variant produces the same degradation trajectory across runs. |
| Detectable | An attentive agent can notice the degradation: the degraded element stays visible in the DOM, the response body carries the HTTP status, and a form readback reveals corrupted values. |
| Recoverable | A competent agent can work around the degradation with a bounded number of extra actions. Interventions filter capability; they do not block it. |
| Primitive-pure | One target primitive per variant. Any task with a secondary primitive is narrowed when a variant's target lies outside the task's existing set. |
| Realistic | Every action corresponds to a real-world failure class: slow network, expired session, phishing email, broken layout, rate limit, 409 conflict. |
Scoring runs against live environment state rather than the DOM. The evaluator reads the Pydantic state objects the real API handlers mutate, so a silently-dropped write is caught even when the UI shows a success toast. Every task declares a canonical_diff block, and matching runs through webstress/eval_core/. (The legacy expression-based eval.checks path was removed in the 2026-04 refactor; all 519 tasks now carry a canonical_diff.)
# webstress/eval_core/orchestrator.py def evaluate(task, server_state, targets, trajectory): canonical = task.canonical_diff # missing_canonical_diff → score 0 initial = server_state._initial_state_copy # deep-copy taken at session create agent_diff = compute_diff(initial, server_state) # eval_core/diff.py report = match_diff(agent_diff, canonical, # eval_core/matcher.py targets, initial, server_state, session_start) return { "score": report.score, "success": report.passed, "checks": report.checks, # positive match results "negative_checks": report.negative_checks, # invariants + constraints + collateral "failures": report.failures, "collateral": state.compute_collateral(initial), # analytics only "bijection_graphs": report.bijection_graphs, }
| Section | Role | Contributes to score via |
|---|---|---|
create | Entities the agent must bring into existence (e.g. new CartItem, new Message). | Weighted positive match; bijection from expected entities to actual diff entries. |
update | Existing entities whose fields must change in specified ways. | Weighted positive match against UpdateEntry.changes. |
delete | Entities the agent must remove. | Weighted positive match against observed DeleteEntrys. |
invariant | Collections that must be preserved. Optional filter narrows scope (e.g. every cart item except the target). A matching unmatched diff entry is a violation. | Negative check (medium penalty by default). |
constraints | Global predicates over final state (read-only). Carry their own severity. | Negative check, or the sole positive term if no create/update/delete exists. |
Two auxiliary structures extend the grammar: oneof wraps multiple acceptable alternatives at the block level and the matcher picks the best-scoring applicable alternative; named_invariants attach human-readable names and severities (critical / high / medium / low) to the invariant list.
# Task YAML (amazon_add_single_item.yaml, abridged) canonical_diff: create: - entity: CartItem desc: Target product added to cart with quantity 1 properties: product_id: {expr: "x == target['product_id']"} quantity: {eq: 1} product_name: {any: true} invariant: - collection: state.cart_items filter: "a.product_id != target['product_id']" # preserve everything but the target preserve: ALL named_invariants: - {name: Agent did not place orders, ref: invariant[4], severity: high}
Properties of a create/update are matched by predicates, each a single-key mapping. eval_core/predicates.py
| Category | Predicates |
|---|---|
| Scalar | eq, in, between, any |
| Collection | set_eq, subset, superset, contains, length |
| Text | substring, substring_all, substring_any, regex, matches_semantic (difflib ratio, default threshold 0.8) |
| Structural | fields (nested dict predicate) |
| Boolean | not, all_of, any_of |
| Expression | expr: restricted Python. Bindings: x (current value), v (bijection variable), target, initial, state, session_start. Allowlist-guarded via eval_core/safe_eval.py. |
After matching the explicit sections, the matcher scans every unmatched diff entry and penalises the agent for uncovered state mutations:
| Situation | Penalty |
|---|---|
| Mutation in a collection the task cares about (mentioned by create/update/delete) but not covered by any matched entry or invariant. | medium (0.15), labelled Unaccounted <kind> in <collection> |
| Mutation in a collection the task does not mention at all. | high (0.20), labelled Unexpected <kind> on <collection> |
Inside a filtered invariant marked comprehensive: true. | suppressed (filter is treated as covering the whole collection). |
The agent is not required to enumerate every side-effect explicitly; a collection-level invariant blocks the sweep. The sweep exists to catch overreach: a successful "add to cart" that also silently writes a review or cancels an order loses score.
eval_core/matcher.py Severity table: critical 0.30, high 0.20, medium 0.15, low 0.10.
if total_weight > 0: # create/update/delete present raw_score = passed_weight / total_weight elif constraints_total: # constraint-only task raw_score = constraints_passed / constraints_total else: raw_score = 1.0 penalty_total = sum(nc.penalty for nc in negative_checks if not nc.passed) score = max(0.0, min(1.0, raw_score - penalty_total)) success = not failures # any hard failure → not success
state.emails[] and state.cart_items[] mutate only through real API handlers. A silently-failed star earns no credit because the evaluator reads the live GmailState, not the rendered page. An agent that adds the right cart item but also silently triggers a purchase loses score because the collateral sweep flags the unaccounted order.
We evaluate eight frontier models under two action surfaces: a vision harness (BrowserGym observations, screenshot plus accessibility tree) and a text harness (Browser Use, DOM-structured text actions). The same variant and seed is used for both surfaces, so the cross-surface gap isolates the contribution of visual grounding.
| Qwen3-VL 235B A22B | Kimi 2.5 | Sonnet 4.6 | Opus 4.7 | Gemini 3 Flash | Gemini 3.1 Pro | GPT 5.4 mini | GPT 5.4 | |
|---|---|---|---|---|---|---|---|---|
| Vision (BrowserGym) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Text (Browser Use) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
All models are called through their chat API. The vision and text harnesses differ only in how observations are presented and which action verbs are available; the variant catalog, seeding, and evaluator are identical.
Human trajectories serve two distinct purposes. We collect both on every task, across both the baseline and the intervened versions.
A participant sees the task for the first time and completes it without prior exposure to the environment. We record pass rate (did the task succeed under the scoring rubric) and completion time. This is the human headroom reference: the gap between cold-start human accuracy and frontier-agent accuracy quantifies the capability deficit on each primitive.
A proficient participant who already knows the environment completes the task along the shortest sensible path. We record the action sequence and its length. This trajectory is the efficiency reference: an agent's step count is compared against the optimal trajectory to produce an efficiency ratio independent of raw success.
| Metric | Human signal | What it tells us about agents |
|---|---|---|
| Success rate | Cold-start pass rate | Capability ceiling: how much of the task distribution is humanly solvable at all. |
| Primitive delta | Cold-start pass rate on baseline vs. intervention | How much each primitive costs a fresh human — and therefore how much of any agent drop is attributable to the intervention rather than task difficulty. |
| Efficiency | Optimal trajectory step count | Agent step count divided by optimal step count gives a normalized efficiency score; successful but inflated agent runs surface here. |
Seven fully interactive web environments, each a faithful reproduction of a real-world platform. Each ships a React SPA, a FastAPI backend, Pydantic state models, and YAML-seeded initial data.
| Environment | Domain | Typical task | Why it is in the set |
|---|---|---|---|
| Gmail | Find an action item, star a thread, draft a reply. | High-volume daily task; heavy grounding and state-tracking load. | |
| Amazon | E-commerce | Add-to-cart, modify quantity, check out. | Multi-step commitment flow; verification and grounding under adversarial surfaces. |
| Social | Post, comment, navigate a thread. | User-generated noise; adversarial content tests. | |
| Robinhood | Finance | Place or modify an order, check a position, read a notification. | Irreversible actions with real-world analogue; verification under time pressure. |
| Booking.com | Travel | Search, filter, reserve a property. | Long filter chains; planning and state tracking. |
| LMS | Education | Find a syllabus item, submit an assignment, read an announcement. | Hierarchical navigation; exploration load. |
| Patient Portal | Healthcare | Message a provider, schedule a visit, read a lab result. | High-stakes content; verification and grounding against clinical messages. |
webstress/
injector/
config.py # DegradationConfig dataclass + default templates
seed.py # seed-layer actions
server.py # server-layer actions
middleware.py # DegradationMiddleware (primary network interception)
network.py # Playwright page.route() parity
apply.py # orchestrator
variants/ # 530 YAML variants
backend/
models/{env}.py # Pydantic state shapes
routes/{env}.py # REST endpoints + session create/evaluate
seeders/{env}.py # YAML → seeded state
tasks/
_schema.py # TaskDefinition, EvalConfig
_registry.py # YAML discovery
_evaluator.py # restricted-eval checker
{env}/ # per-env task YAMLs
environments/
{env}/ # React SPA per env
shared/src/components/BenchmarkToolbar.tsx # client-layer injections
# Install uv sync && playwright install chromium pnpm -C environments install && pnpm -C environments build # Baseline run (no intervention) python -m webstress.agent_eval \ --model gpt-4o --provider openai --api-key $KEY \ --tasks gmail_action_item_extraction \ --max-steps 50 --seed 42 --output results/baseline.json # Intervention run (same task, one variant) python -m webstress.agent_eval \ --model gpt-4o --provider openai --api-key $KEY \ --tasks gmail_action_item_extraction \ --degradation gmail_action_item_extraction__phishing_inbox.yaml \ --max-steps 50 --seed 42 --output results/phishing.json # Visualize any result JSON python -m webstress.visualize results/phishing.json # Human play (collects cold-start and optimal trajectories) python -m webstress.app --host 127.0.0.1 --port 8080 # Open http://127.0.0.1:8080/launch