Code: s1a/browser/ (decision_model.py,
action_space.py, probe_js.py, prompts.py), the decision-model layer in s1a/decision_models/
(docs/decision-models.md) and the HTTP transport in s1a/decision_models/wire.py. Tests: tests/test_browser_policy.py
and tests/test_browser_recovery.py. The
harness side (the DecisionPolicyModel Protocol, probe_for_policy and activate_page on the Playwright
runtime, the policy path in create_browser_agent) is the decision-policy slot pinned in pyproject.toml.
Measurements: docs/benchmarks.md.
Every step of openJiuwen's browser subagent is one chat-model turn: the model reads a 12k-character
<browser_state> and writes a tool call as free text. Measured at seconds per step, with targets the runtime
sometimes has not registered. Jev is a System 1 decision model: one request holds a state and several
choice questions, the answer holds one option per question with a probability distribution and a
confidence, and the option can only be one of the offered indices. The reference implementation is
browser-use/jev-ultrafast (MIT), whose observe-decide-act tick this policy follows.
-
A decision model fills the
Modelslot.BrowserDecisionModel(Model)answers a browser turn (the tool list holdsbrowser_click) with exactly onebrowser_*tool call and forwards every other turn (summaries, typed values) to the wrapped chat model. DeepAgent, rails, checkpoints and the permission engine are untouched. The model behind it is aDecisionModel: TypeSafe Jev over HTTP with--model jev, Laya in process with--model laya; the policy is the same.action_space.pybuilds theObservationand the typed questions of a tick (build_observation,build_questions) and reads the answer back onto a candidate (interpret). -
The policy has its own in-page probe.
probe_js.pywaits for the page to settle and describes every visible control once. Each control is stampeddata-opens1a=<id>; the stamp is the element's identity across ticks and the unique selector the runtime registers.browser_click(target_id)then passes the runtime's target validation. The runtime's own snapshot was not reused: 85 to 156 ms per call, empty<input>values, a 40-item cap, and a selector-uniqueness heuristic that drops most of Google's controls. -
Settle first, decide once. The probe returns after
readyState, a 60 ms DOM-quiet window (cap 500 ms) and, after typing into a combobox, rendered autocomplete options (cap 900 ms, document-wide[role=option]). Settling uses timers;requestAnimationFramenever fires in a hidden tab. A last-resort resolver keeps an unresolved promise from reaching the driver timeout. This policy discards no answers. jev-ultrafast decides on every page change and discards stale answers, 8 to 11 per run in measurement. -
Hidden tabs are activated. A hidden tab throttles timers to about 1 Hz and does not render dropdowns. When the probe reports
visibilityState == "hidden", the policy callsactivate_page(url)once and probes again; a failed re-probe is refused like any other failed observation (item 8). -
The chat model generates typed values in the background before the field is reached. A
TYPE_TEXTstring is generated from the goal, the field, the page text and the history. With--prefetch on(BrowserPolicy.prefetch_values, the default) that call starts in the background for every editable field as soon as a probe shows it, keyed by URL host and path, stamp id and label. The value survives page-key changes within one document and is cancelled on navigation. Measured: value wait 0 ms with prefetch on (9 to 15 calls per run), 2.0 to 2.4 s off (3 calls).--goal-values on(BrowserPolicy.goal_value_cache) adds atext_valuechoice head over values extracted from the goal; off by default. -
The harness recognises a policy structurally.
openjiuwen/harness/schema/decision_policy.pydefines a runtime-checkableDecisionPolicyModelProtocol with one method,bind_runtime(runtime).create_browser_agentchecksisinstance(model, DecisionPolicyModel): no temperature copy, no LLM-only context processors, anomaly-detection rail off,bind_runtime(browser_backend). The factory never imports this package. -
One
_Runper task. Goal, history, pending action, prefetched values, tick counters and the settle budget live in a dataclass; a new goal or a finished run starts a fresh one and cancels the previous run's background tasks. The decision model isself._decision_model;Model.__init__buildsself._clientas a real telemetry-bearing model client and inherited methods touch it. -
A failed observation is refused, not decided. The runtime reports a failed probe as
ok=Falseor anerrorfield. The policy checks the snapshot at the observation boundary (_require_observation) and raisesRuntimeError("browser observation failed: ...")with a bounded one-line reason before the decision model or the chat answer can read an empty page. This keeps a broken browser (a missing library such aslibatk-1.0.so.0, a closed page) from being answered DONE; the recovery refresh and the in-page action-settle/WAIT probes keep their own bounded handling. A decisions transport error, HTTP error, malformed 200 body or invalid distribution ends the turn with aBLOCKEDsummary that still holds URL, title, steps and page text. Response bodies never enter error messages or logs. -
Answers are validated in the decision-model layer.
decide_manyaccepts only a choice among the offered ids whose distribution covers exactly those ids, sums to 1 within 0.02, and peaks at the choice, for every head of the request, including the heads the choice did not select. An unusable answer is re-asked once with the same request (DECISION_ATTEMPTS); a transport error is final.interpretthen reads a validatedDecision. -
WAIT is spent in-page. On a WAIT verdict the policy re-probes with a doubling settle window (500, 1000, 1500 ms) until the
page_keychanges or the streak budget is gone; a decisions request on an unchanged page returns the same answer and costs a full request. Two bounds hold together:MAX_CONSECUTIVE_WAITS(5 verdicts) andWAIT_SETTLE_BUDGET_MS(3000 ms per streak, kept on the run so repeated verdicts cannot re-grant it). An exhausted budget is terminal. -
Actions settle before the model is asked. When the probe after an action shows the same
page_key, the policy waits in-page from 250 ms, doubling, up toACTION_SETTLE_BUDGET_MS(1000 ms), counted against the same streak budget. Google Flights closes its date dialog 300 to 700 ms after "Done"; without this the model saw the old page and answered WAIT at request cost. Measured: 13 decisions and 2 waits per run became 12 and 1. -
Two rules the rewrite had lost are back in
OPERATION_RULES: a filled search field is not a submitted search, and a visible Search or Submit control with its required fields filled is pressed at once. Without them the model answered DONE before pressing Search in 3 of 3 runs. -
Constants and their derivation (
decision_model.py):constant value constraint PROBE_SETTLE_MS500 equals the JS default; an un-escalated probe's timing is unchanged MAX_PROBE_SETTLE_MS1500 load(3 s) + settle + 1 slast-resort must stay at least 1 s under the 30 s transport timeoutWAIT_SETTLE_BUDGET_MS3000 in-page wait one WAIT streak may spend before BLOCKED ACTION_SETTLE_START_MS,ACTION_SETTLE_BUDGET_MS250, 1000 first and total post-action wait DECISIONS_TIMEOUT_S5 Jev answers in 0.4 to 1.3 s through the proxy; a dead connection must not stall a step; one transport retry; S1A_DECISION_TIMEOUT_Soverrides it for a slower System One server (configuration.md)DECISION_ATTEMPTS2 one re-ask of the same request when an answer fails validation; a transport error is final BATCH_ACTION_TIMEOUT_MS2000 a stamp lost to a re-render fails fast; the returned probe re-stamps -
Two decisions backends, separate keys.
typesafetalks toapi.typesafe.ai/v1/systemonewithTYPESAFE_API_KEYandjev-latest;openroutertalks to/api/alpha/decisionswithOPENROUTER_API_KEYandtypesafe/jev-1.13(TYPESAFE_API_URLandTYPESAFE_MODELoverride the proxy only). SettingTYPESAFE_API_URLselects the proxy backend, which readsOPENROUTER_API_KEY; the TypeSafe key is never sent to OpenRouter. Measured: 294 to 418 ms per decision direct, 450 to 510 ms through the proxy. -
Batched actions (
--batch on,BrowserPolicy.batch_actions, needs theunsafe_devbrowser capability). Each step is onebrowser_run_code_unsafecall that performs the action and returns the next probe; one transport round trip per step. The target is the stamped selector from the last probe; a missing stamp re-probes and re-finds the element by role and label; an action that still fails returns the probe witherrorset, and the policy re-decides on fresh stamps. The runtime's target validation is skipped on this path. -
DONE answers from the whole page. On DONE the policy probes once more with
all_text=True(up to 20,000 characters, viewport filter off) and asks the chat model for the answer; the terminal summary holds it underanswer, next to a 2,000-characterpage_textexcerpt for diagnosis. The harness caps the subagent's summary at 8,000 characters; the page text cannot be included in the final message. -
PRESS_ENTER, a keyboard submit. An editable searchbox, textbox or combobox that already holds a value gets a
PRESS_ENTERcandidate next to its TYPE_TEXT and CLICK ones; an empty field, a read-only one and a button do not. Unbatched it is onebrowser_press_keyof Enter, which goes to the focused field, the one just typed into; batched it istarget.press("Enter")on the field's own locator. The case comes from the Allrecipes batch: the query was typed, then the site's own search button was clicked four times without a page change. -
Dead targets are withheld. A control whose last two clicks left the page unchanged keeps its table row, marked
click_did_nothing, and loses its CLICK candidate until a click on it moves the page again. The probe's occlusion check sees what covers a control now; a consent banner that returns after every navigation leaves the control looking clickable on each fresh probe. In the Allrecipes batch the run ended on the no-page-change guard after three such clicks. -
Bounded recovery (
--rethink on, off by default;--rethink-attempts, default 3;--rethink-timeout, default 15 s). A stop signal --stall_afteractions without a page change, an A-B-A-B page loop, a fourth arrival at one URL, an over-long WAIT streak or a WAIT settle that moved nothing -- spends one attempt instead of ending the run. The attempt re-probes the page read-only through_raw_probe(never a navigation; a probe error or a denied read stops immediately) and, when thepage_keyreally did not move, asks the chat model for a plan through the tool front'sdraft_plan; a changedpage_keyis delayed progress and skips the planner. The plan rides into the next observation only and never executes a click; the offered tools and candidates are filtered exactly as an ordinary turn filters them, so recovery cannot widen the tool set or addunsafe_dev.RecoveryLimitsbounds the attempts and the seconds spent inside the refresh and planner calls, counted fromtime.perf_counterand cancelled byasyncio.wait_for, cumulatively and never reset by progress; the outer--max-stepsand--timeoutstill cap the task. Only the detection windows (the history boundary the stall and oscillation guards read, the WAIT streak and the visit counts) reset after an attempt: history, ticks and the budget stay, so a plan gets a few actions before the same stall can fire again. A refresh or planner timeout, error, cancellation or an exhausted budget ends the run BLOCKED with no answer fetched;finish_decision_modelreports it as a failure even if the summary carries text. A policy that still answers BLOCKED after one or more replans is a failure too: the partial answer is kept only as context, the summary carries the block reason and a next action, and the record is not mislabelled as a failed or exhausted recovery or a failed planner call.--model llmwith--rethink onis rejected before the agent or browser is built.report()anddecision_ticks.jsonkeep the recovery events, attempts, seconds and termination. Failed recovery also includes a next action for the operator; refresh/planner errors record a short failure category without the provider response body. Failed and cancelled planner calls remain in the call count, with unknown token usage kept separate from zero cost. To compare, run the same goal twice,--rethink offagainston, on the same backend; the on/off difference is only worth quoting once the browser bench is rerun, because the scripted tests pin the loop, not a task success rate. -
The final page is saved. However a task ends, a timeout included,
run_taskinbrowse.pytakes one viewport PNG of the live page before it releases the browser, saves it asfinal.pngin--logs-dirand puts that path in the answer'sscreenshot. A judge that grades the end state can read the file, as Harbor's WebVoyager judge does at/logs/agent/final.png. The runtime is the onecreate_browser_agentgave the agent'sBrowserRuntimeRail, and the call isbrowser_take_screenshot. @playwright/mcp writes the PNG under its output directory (.playwright-mcp/in its cwd, or--output-dir) and links it in its report, relative to its cwd. The runtime's MCP client drops the inline image, so the file is copied from that link. The link's path is written raw, soSCREENSHOT_LINKreads the whole- [Screenshot of ...](...)line and a directory such asshots (1)keeps its parentheses. Afinal.pngan earlier run left in a reused--logs-diris removed when the task starts, so a failed or skipped screenshot never leaves a judge the earlier page. Nothing is taken when the runtime never observed a page, because the tool would launch a new browser to take one. The call is capped atSCREENSHOT_TIMEOUT_S(5 s, @playwright/mcp's own screenshot timeout). A failure leavesscreenshotnull and records only the exception type inscreenshot_error, because the exception text can quote the page.elapsed_msstops before the screenshot, and neither the outcome nor the release of the browser and the session depends on it. Tests:tests/test_browse.py, andtests/system/test_final_screenshot.pyon a live @playwright/mcp.
- A
browser_jev_runtool: the chat model would still decide when to delegate, one extra turn per step. - Jev as a browser driver backend: a driver has eyes and hands only; the decision would be lost.
- Reusing the runtime's snapshot probe: decision 2.
requestAnimationFramefor settling: never fires in a hidden tab.- An "Open " prefix on the click head for editable fields, as jev-ultrafast does: tried, 5 of 6 runs still chose TYPE_TEXT on the date box; withdrawn.
- Re-asking the model on an unchanged page after a WAIT: decision 10.
browser_run_code_unsafewithpage.screenshot({path})for the final page, the ways1a/tool/hands.pytakes frames: @playwright/mcp waits 500 ms after the code, and longer when the page made requests meanwhile.browser_take_screenshotwithfilenameset to the final page's path: @playwright/mcp refuses a file outside its cwd and its output directory, and--logs-dircan be anywhere (Harbor's is/logs/agent).- The process-wide
_ACTIVE_BROWSER_RUNTIMESset as the final page's runtime:s1a-mcpruns one task after another in one process, and the set can still hold an earlier task's runtime.
- The date box: the model picks TYPE_TEXT on a field labelled "Departure" in 4 of 9 runs and the value
model then types a city. Next step: put the input's
type,placeholderandautocompleteinto the element table so the operation question can see "this is a date field". - The declarative subagent path (
SubAgentConfig) has no decision-policy option; onlycreate_browser_agent(model)takes a policy. - The decisions client is a plain
httpx.AsyncClient, outside the harness's connection pool. - Budget exhaustion is treated as terminal on the assumption that the model answers the same WAIT for an unchanged snapshot; if a live run shows otherwise, re-ask once before BLOCKED.
- The runtime's
navigate,evaluateandpress_keyare private;s1a/tool/hands.pyreaches them through_call_playwright_tooland_execute_probe_json(upstream ask indocs/roadmap.md).browse.pytakes the final screenshot through_call_playwright_tooltoo and reads the runtime offBrowserRuntimeRail._runtime; the agent has no public handle to it.