← Back to launcher

PrimBench: Diagnosing Cognitive Primitives of Web Agents via Environment Interventions

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.

7
Environments
519
Base Tasks
530
Intervention Variants
7
Primitives
4
Injection Layers
29
Intervention Families

1. Motivation

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.

One-line summary. Replace "did the agent finish the task" with "for each primitive, did the agent withstand a targeted stressor." The first number ranks; the second number trains.

2. The Seven Cognitive Primitives

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.

Grounding

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.

Stressed by: phishing emails, alias entities, label-input misalignment, distractor modals

Planning

Decompose a goal into ordered sub-goals and respect dependencies between them. Keep the plan consistent as new information arrives.

Stressed by: scrambled timestamps, missing prerequisites, stale first-search results

State Tracking

Maintain a working model of what is done versus pending across a multi-step trajectory. Reconcile updates that arrive out of order.

Stressed by: shuffled contacts, split information, contradictory updates, repeated-contradicted haystacks

Backtracking

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.

Stressed by: session expiry (401), 409 conflicts, planted wrong answer, skeleton that never resolves

Patience

Know when to wait. Calibrate retry timing through slow, flaky, or rate-limited operations, and distinguish a loading state from a failure.

Stressed by: tail latency, progressive delays, rate limits (429 + Retry-After), correlated slow windows

Exploration

Discover alternative affordances and paths when the obvious one fails. Try the non-default entry point rather than abandon the task.

Stressed by: restrict_affordance_set (only one of image/title works), hidden prerequisites, intercepting overlays

Verification

After performing an action, check that it actually achieved its intended effect. Do not take a success banner at face value.

Stressed by: silent_fail, misleading_success (toast reads "Saved"), click_swallow, save_drift, input_corruption

Coverage and Non-Overlap

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.

PrimitivePrimary stressor classRepresentative action
GroundingAdversarial or near-identical contentinject_adversarial_content, add_confusing_decoys, label_input_misalignment
PlanningDisordered prerequisitesscramble_timestamps, hide_prerequisite
State TrackingDivergent or fragmented statesplit_information, add_contradictory_update, shuffle_positions
BacktrackingHard failure requiring alternativesession_expiry, concurrent_modification, plant_wrong_answer
PatienceLatency or rate pressuredelay (tail_latency, progressive), rate_limit
ExplorationClosed default pathrestrict_affordance_set, hide_in_non_obvious_location, intercepting_overlay
VerificationFalse positive feedbacksilent_fail, misleading_success, save_drift, click_swallow

3. How We Intervene: Four Layers

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.

Why Four Layers Are Needed

LayerCapturesCannot be expressed by the others
SeedContent 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.
ServerStructural 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.
NetworkReal 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.
ClientInteraction 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.

When Each Layer Fires

Seed · once, at session creation
Server · once, after seed
Network · per request
Client · per interaction
Seed
Data mutations applied at session creation, before the agent sees the page. Typical actions: inject phishing content, add confusing decoys, split information across items, inflate a target body with realistic filler.
injector/seed.py
Server
Structural mutations applied after seed, before the first response. Typical actions: shuffle list ordering, scramble timestamps, hide a label, corrupt a single field.
injector/server.py
Network
HTTP interception on every matching API call. Typical actions: tail latency, 503 then 200, silent 200 with the write dropped, 429 with Retry-After, 401 until reauth, 409 with a newer snapshot.
injector/middleware.py
Client
DOM and interaction mutations via document-level delegated listeners. Typical actions: swallow the first N clicks, corrupt typed input, rewrite a submitted date by minus one day, near-invisible overlay, label-input misalignment.
BenchmarkToolbar.tsx

Worked Example: A Patience Intervention

agent / harness BrowserGym task FastAPI routes Middleware injector/* SETUP · load variant · apply Seed/Server · register Network/Client DegradationConfig.from_yaml(path) POST /session {task_id, seed, variant_filename} seed.apply_seed_injection(state, params) server.apply_server_injection(state, params) register_session_degradation(session_id, injections) RUNTIME · middleware intercepts API calls env.step('click("75")') · stars email SPA: POST /api/env/gmail/emails/email_3/star dispatch() 1. extract session_id 2. match URL pattern 3. call_count ≤ 2 4. return 503 ↑ HTTP 503 {"error": "...retry..."} obs (star had no visible effect) send_msg_to_user("Done") · no retry EVALUATE · server state (is_starred == False) → score 0.0 POST /evaluate {score: 0.0} · email.is_starred == False
Ground truth stays independent of the UI. The middleware returns a real HTTP 503 for the /star call, not a Playwright mock; the same 503 reaches a human browser. A patient agent retries and succeeds; an impatient agent sends "Done" and scores zero. The evaluator asks the live GmailState whether the email is starred, so a silently-dropped write is caught regardless of what the DOM shows.

4. Catalog Map

The full catalog binds base tasks, intervention variants, and primitive coverage across seven environments.

Tasks and Variants per Environment

EnvironmentDomainBase TasksVariantsAdversarial-content surface
GmailEmail8494Email body and sender display name
AmazonE-commerce7070Product reviews and notifications
RedditSocial8181Post body and top comment
RobinhoodFinance7171Notifications (security_alert type)
Booking.comTravel7878Property reviews
LMSEducation6565Announcements and discussion posts
Patient PortalHealthcare7071Clinical messages
Total7 domains519530

Intervention Families by Layer

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.

LayerFamiliesDispatch branchesPrimary primitives targeted
Seed714grounding, state tracking, exploration, backtracking, verification
Server510planning, state tracking, grounding, exploration, verification
Network78patience, verification, backtracking, state tracking
Client1016grounding, verification, exploration, backtracking, patience
Total2948all seven primitives

Seed Families injector/seed.py

FamilyWhat it doesDispatchesPrimitives
Decoys & aliasesInserts 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_stocksgrounding
Adversarial contentPlants 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_alertgrounding, verification
Split informationDistributes task-critical information across multiple items so the agent cannot resolve the task from any single one.split_informationstate tracking
Contradictory updateInserts a newer item that contradicts an older one. The agent must reconcile to the newer value.add_contradictory_updatestate tracking
Content inflationPads 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_contentstate tracking, exploration
Planted wrong answerPlaces a plausible-but-incorrect answer first, so a greedy agent commits to it without continuing to search.plant_wrong_answerbacktracking
Hidden targetBuries the target item in an atypical folder, archive, or tab.hide_in_non_obvious_locationexploration

Server Families injector/server.py

FamilyWhat it doesDispatchesPrimitives
Timestamp scrambleApplies random offsets to timestamps within the scope. The apparent chronology no longer matches causal order.scramble_timestamps, scramble_order_timestamps, scramble_notification_timestampsplanning
Ordering shuffleRandomises list ordering so visual position is uninformative; the agent must re-derive ordering from semantic fields.shuffle_contacts, shuffle_positionsstate tracking
Distractor injectionAdds realistic-looking but irrelevant entries inline with real ones.inject_distractor_emails, inject_distractor_notificationsgrounding
Prerequisite hidingRemoves a label or list that the task description assumes already exists. The agent must create the prerequisite or find a workaround.hide_prerequisite, hide_watchlistexploration
Field corruptionModifies a specific field to introduce an inconsistency the agent must notice and repair on readback.corrupt_stateverification

Network Families injector/middleware.py

FamilyWhat it doesDispatchesPrimitives
LatencyInserts delay before forwarding to the real handler. Behaviour modes: once, intermittent, progressive, tail_latency (piecewise-linear p50/p95/p99), correlated_window, write_only_slow.delaypatience
Transient errorReturns 503 / 500 / 502 / 429 for the first N calls, then passes through. Sets Retry-After on 429.error_then_successbacktracking, patience
Fabricated successReturns 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_successverification
Stale responseReturns outdated or empty body for the first N GETs. The agent must reconcile a read that does not reflect a prior write.stale_datastate tracking
Optimistic conflictReturns 409 Conflict with a latest_snapshot of the "newer" state. The agent must reload and reconcile before retrying.concurrent_modificationbacktracking
Rate limitAfter burst_limit calls, returns 429 with Retry-After for the next cooldown_calls requests. Tests whether the agent reads structured error responses.rate_limitpatience
Session expiryAfter expire_after_calls, returns 401. Cleared by a request to reauth_path.session_expirybacktracking

Client Families BenchmarkToolbar.tsx

FamilyWhat it doesDispatchesPrimitives
Label misbindingShifts 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_misalignmentgrounding
Decoy elementClones a clickable element and strips its handler, inserted adjacent to the real control.add_decoygrounding
Hidden/restricted affordanceHides the default entry point or disables all but one of a row's redundant controls (image / title / menu / primary button).hide_affordance, restrict_affordance_setexploration, backtracking
Deceptive bannerInjects a misleading alert banner above the page content.false_bannergrounding, verification
Swallowed clickFirst N clicks on a matching selector are no-ops; subsequent clicks work normally.click_swallowverification, patience
Input perturbationCorrupts 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_driftverification
Double-fire trapA second click on a submit-style button within window_ms fires the action twice.double_submit_trapverification, patience
Intercepting overlayNear-invisible overlay (opacity 0.02) swallows clicks over a region. Dismissed by Escape or an 18×18-px corner button.intercepting_overlayexploration, patience
Stuck loaderLoading skeleton on a specific route never resolves; only refresh or navigation clears it.skeleton_never_resolvesbacktracking, patience
Interrupting modalInjects a cookie / newsletter / survey modal on the Nth navigation. Close control is deliberately small (12 px); Escape dismisses.distractor_modalgrounding, 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.

Primitive Coverage Across Environments

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.

GmailAmazonRedditRobinhoodBookingLMSPortal
Grounding
Planning
State Tracking
Backtracking
Patience
Exploration
Verification

Design Invariants

DeterministicEvery action takes a seed. The same variant produces the same degradation trajectory across runs.
DetectableAn 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.
RecoverableA competent agent can work around the degradation with a bounded number of extra actions. Interventions filter capability; they do not block it.
Primitive-pureOne target primitive per variant. Any task with a secondary primitive is narrowed when a variant's target lies outside the task's existing set.
RealisticEvery action corresponds to a real-world failure class: slow network, expired session, phishing email, broken layout, rate limit, 409 conflict.

5. Evaluation System

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.)

Pipeline

# 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,
    }

Five Grammar Sections of canonical_diff

SectionRoleContributes to score via
createEntities the agent must bring into existence (e.g. new CartItem, new Message).Weighted positive match; bijection from expected entities to actual diff entries.
updateExisting entities whose fields must change in specified ways.Weighted positive match against UpdateEntry.changes.
deleteEntities the agent must remove.Weighted positive match against observed DeleteEntrys.
invariantCollections 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).
constraintsGlobal 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}

Predicate Grammar (19 keys)

Properties of a create/update are matched by predicates, each a single-key mapping. eval_core/predicates.py

CategoryPredicates
Scalareq, in, between, any
Collectionset_eq, subset, superset, contains, length
Textsubstring, substring_all, substring_any, regex, matches_semantic (difflib ratio, default threshold 0.8)
Structuralfields (nested dict predicate)
Booleannot, all_of, any_of
Expressionexpr: restricted Python. Bindings: x (current value), v (bijection variable), target, initial, state, session_start. Allowlist-guarded via eval_core/safe_eval.py.

Automatic Collateral Sweep

After matching the explicit sections, the matcher scans every unmatched diff entry and penalises the agent for uncovered state mutations:

SituationPenalty
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.

Score Formula

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
Why server state. The DOM is manipulated by client-layer interventions, but 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.

6. Evaluated Agents

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.

7. Human Baseline

Human trajectories serve two distinct purposes. We collect both on every task, across both the baseline and the intervened versions.

Cold-Start Baseline

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.

Optimal Trajectory

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.

How the Two Baselines Combine

MetricHuman signalWhat it tells us about agents
Success rateCold-start pass rateCapability ceiling: how much of the task distribution is humanly solvable at all.
Primitive deltaCold-start pass rate on baseline vs. interventionHow much each primitive costs a fresh human — and therefore how much of any agent drop is attributable to the intervention rather than task difficulty.
EfficiencyOptimal trajectory step countAgent step count divided by optimal step count gives a normalized efficiency score; successful but inflated agent runs surface here.
Why both baselines. Raw human pass rate does not separate "fresh user thinking from scratch" from "expert acting optimally." Cold-start pass rate measures the first; optimal trajectory measures the second. Agents are compared against both: accuracy against cold start, efficiency against optimal.

8. Environments

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.

EnvironmentDomainTypical taskWhy it is in the set
GmailEmailFind an action item, star a thread, draft a reply.High-volume daily task; heavy grounding and state-tracking load.
AmazonE-commerceAdd-to-cart, modify quantity, check out.Multi-step commitment flow; verification and grounding under adversarial surfaces.
RedditSocialPost, comment, navigate a thread.User-generated noise; adversarial content tests.
RobinhoodFinancePlace or modify an order, check a position, read a notification.Irreversible actions with real-world analogue; verification under time pressure.
Booking.comTravelSearch, filter, reserve a property.Long filter chains; planning and state tracking.
LMSEducationFind a syllabus item, submit an assignment, read an announcement.Hierarchical navigation; exploration load.
Patient PortalHealthcareMessage a provider, schedule a visit, read a lab result.High-stakes content; verification and grounding against clinical messages.

9. Repository Structure (abridged)

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

10. Running PrimBench

# 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
PrimBench · Diagnosing Cognitive Primitives of Web Agents via Environment Interventions