Skip to content

browser: optional auto-screenshot after each action - #1135

Open
resumeparseeval wants to merge 2 commits into
moltis-org:mainfrom
resumeparseeval:feat/browser-auto-screenshot
Open

browser: optional auto-screenshot after each action#1135
resumeparseeval wants to merge 2 commits into
moltis-org:mainfrom
resumeparseeval:feat/browser-auto-screenshot

Conversation

@resumeparseeval

Copy link
Copy Markdown
Contributor

Summary

Automatically captures a screenshot after each state-changing browser action and attaches it to that action's tool result, so chat clients can render a per-step screenshot timeline.

  • Capture happens at the single dispatch chokepoint in BrowserManager::execute_action (crates/browser/src/manager.rs). After a successful action whose BrowserResponse.screenshot is still None, a new attach_step_screenshot helper reuses the existing screenshot() + with_screenshot() builder to copy the data-URI and screenshot_scale onto the response.
  • Navigate is handled in its early-return path (after the navigate call); all other state-changing actions (Click, Type, Scroll, Evaluate, Wait, Back, Forward, Refresh) are gated by the new BrowserAction::is_state_changing(). Read-only/meta actions (Screenshot, Snapshot, GetUrl, GetTitle, Close) are excluded.
  • Best-effort: renderless browser kinds (Obscura/Lightpanda, where supports_screenshots() is false) error on capture — the error is ignored and the original response is returned unchanged. The auto-screenshot never fails the action.
  • Gated by a new screenshot_each_step: bool flag (default true) on [tools.browser], mirrored onto the runtime moltis_browser::BrowserConfig + its From impl, and threaded into BrowserManager. With the flag false, behavior is unchanged.

No new media/HTTP/persistence code: the chat layer already auto-saves any BrowserResponse.screenshot data-URI to the media store, rewrites it to a media/<key>/<id>.png ref, and strips it from LLM context. The LLM-context sanitizer and max_tool_result_bytes are untouched.

Per repo conventions, the new config field is also wired into build_schema_map() (crates/config/src/validate/schema_map.rs), the config template (crates/config/src/template.rs), and the docs (configuration-reference.md, browser-automation.md).

Validation

Completed

  • cargo build -p moltis-browser -p moltis-config -p moltis-tools
  • cargo clippy -p moltis-browser -p moltis-config -p moltis-tools — clean on changed files
  • cargo test -p moltis-browser --lib — new state_changing_actions_request_step_screenshots and screenshot_each_step_defaults_on tests pass
  • cargo test -p moltis-config — 347 tests pass (schema-map completeness verified)
  • Pinned-nightly cargo fmt --check — no genuine diffs in changed files

Remaining

  • ./scripts/local-validate.sh <PR#> (full OS-aware lint/test suite)

Manual QA

  1. With a real Chromium present and [tools.browser] screenshot_each_step = true (default), issue a navigate / click / type action — the result now includes a screenshot data-URI, and the chat client's automation-steps view shows the per-step image.
  2. Set screenshot_each_step = false — results no longer carry an auto-screenshot; behavior matches prior releases.
  3. Select a renderless browser (browser = "obscura" / "lightpanda") and run a state-changing action — the action succeeds and returns its normal response with no screenshot and no surfaced error.

🤖 Generated with Claude Code

Capture a screenshot after every state-changing browser action
(navigate, click, type, scroll, evaluate, wait, back, forward, refresh)
and attach its data-URI to that action's `BrowserResponse`, so chat
clients can render a per-step screenshot timeline.

Capture happens at the single dispatch chokepoint in
`BrowserManager::execute_action`: after a successful action whose
response carries no screenshot, `attach_step_screenshot` reuses the
existing `screenshot()` + `with_screenshot()` builder. Read-only/meta
actions (screenshot, snapshot, get_url, get_title, close) are skipped
via `BrowserAction::is_state_changing()`. The capture is best-effort —
renderless browser kinds (Obscura/Lightpanda) that cannot screenshot
fail silently and the action returns its original response unchanged.

Gated by a new `screenshot_each_step` config flag (default true) on
`[tools.browser]`, mirrored onto the runtime browser config and threaded
through to the manager. With the flag false, behavior is unchanged.

No new media/HTTP/persistence code: the chat layer already auto-saves
any `BrowserResponse.screenshot` data-URI to the media store, rewrites
it to a `media/<key>/<id>.png` ref, and strips it from LLM context.
@greptile-apps

greptile-apps Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds an optional screenshot_each_step feature to BrowserManager that captures a screenshot after each state-changing browser action (navigate, click, type, etc.) — on both success and failure — and attaches it to the action's response for per-step timeline display in chat clients. The flag defaults to false (opt-in), is wired through config schema, template, schema-map validation, and docs, and the new is_state_changing() predicate on BrowserAction correctly excludes read-only actions.

  • attach_step_screenshot in manager.rs handles the success path; the failure path is handled inline in handle_request with a separate 15 s timeout, but this timeout runs outside the main timeout(timeout_duration, …) wrapper — callers with a short timeout_ms can wait up to timeout_ms + 15 s for an error response.
  • Config, template, schema-map, and docs changes are complete and consistent; BrowserConfig::default() and BrowserConfig TOML deserialization both correctly default the flag to false.
  • Two new unit tests cover is_state_changing() exhaustiveness and the false default.

Confidence Score: 4/5

Safe to merge with a targeted fix to the failure-path screenshot timeout in handle_request.

The failure-path screenshot in handle_request runs outside the timeout(timeout_duration, …) wrapper and uses a hard-coded 15 s cap, meaning error responses can take up to timeout_ms + 15 s total wall-clock time when screenshot_each_step = true.

crates/browser/src/manager.rs — the failure-path screenshot block in handle_request around the inline timeout(Duration::from_secs(15), …) call.

Important Files Changed

Filename Overview
crates/browser/src/manager.rs Adds failure-path screenshot capture and attach_step_screenshot helper; the failure screenshot runs outside the request timeout wrapper and can extend error-response latency by up to 15 s beyond request.timeout_ms.
crates/browser/src/types.rs Adds is_state_changing() on BrowserAction and screenshot_each_step: bool (default false) on BrowserConfig; both well-tested and correctly defaulted.
crates/config/src/schema/tools.rs Adds screenshot_each_step field with default = "default_screenshot_each_step" (returns false); TOML deserialization default is correctly opt-in.
crates/config/src/template.rs Adds commented-out screenshot_each_step = false line to the config template; consistent with opt-in framing.
crates/config/src/validate/schema_map.rs Registers screenshot_each_step as a known leaf key in the schema map; required by repo convention and done correctly.
docs/src/browser-automation.md Documents the new field in the browser config example block; accurate and matches the implementation.
docs/src/configuration-reference.md Adds table row for screenshot_each_step with correct type, default, and description.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Caller
    participant handle_request
    participant execute_action
    participant attach_step_screenshot
    participant screenshot

    Caller->>handle_request: "BrowserRequest (screenshot_each_step=true)"
    handle_request->>handle_request: "want_failure_shot = is_state_changing()"
    handle_request->>execute_action: timeout(timeout_ms, execute_action(...))

    alt Action succeeds
        execute_action->>attach_step_screenshot: attach_step_screenshot(sid, response)
        attach_step_screenshot->>screenshot: screenshot(sid, ...)
        screenshot-->>attach_step_screenshot: Ok(shot)
        attach_step_screenshot-->>execute_action: response + screenshot
        execute_action-->>handle_request: Ok((sid, response))
        handle_request-->>Caller: BrowserResponse (with screenshot)
    else Action fails (non-connection error)
        execute_action-->>handle_request: Err(e) within timeout_ms
        handle_request->>handle_request: build error BrowserResponse
        note over handle_request: timeout(15s) NOT bounded by remaining timeout_ms
        handle_request->>screenshot: timeout(15s, screenshot(sid, ...))
        screenshot-->>handle_request: Ok/Err
        handle_request-->>Caller: error BrowserResponse
    else Action times out
        execute_action-->>handle_request: Timeout
        handle_request-->>Caller: timeout BrowserResponse (no screenshot)
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Caller
    participant handle_request
    participant execute_action
    participant attach_step_screenshot
    participant screenshot

    Caller->>handle_request: "BrowserRequest (screenshot_each_step=true)"
    handle_request->>handle_request: "want_failure_shot = is_state_changing()"
    handle_request->>execute_action: timeout(timeout_ms, execute_action(...))

    alt Action succeeds
        execute_action->>attach_step_screenshot: attach_step_screenshot(sid, response)
        attach_step_screenshot->>screenshot: screenshot(sid, ...)
        screenshot-->>attach_step_screenshot: Ok(shot)
        attach_step_screenshot-->>execute_action: response + screenshot
        execute_action-->>handle_request: Ok((sid, response))
        handle_request-->>Caller: BrowserResponse (with screenshot)
    else Action fails (non-connection error)
        execute_action-->>handle_request: Err(e) within timeout_ms
        handle_request->>handle_request: build error BrowserResponse
        note over handle_request: timeout(15s) NOT bounded by remaining timeout_ms
        handle_request->>screenshot: timeout(15s, screenshot(sid, ...))
        screenshot-->>handle_request: Ok/Err
        handle_request-->>Caller: error BrowserResponse
    else Action times out
        execute_action-->>handle_request: Timeout
        handle_request-->>Caller: timeout BrowserResponse (no screenshot)
    end
Loading

Reviews (2): Last reviewed commit: "fix(browser): default screenshot_each_st..." | Re-trigger Greptile

Comment thread crates/config/src/schema/tools.rs
Comment on lines +291 to 319
async fn attach_step_screenshot(
&self,
sid: &str,
response: BrowserResponse,
sandbox: bool,
browser: Option<BrowserPreference>,
) -> BrowserResponse {
if response.screenshot.is_some() {
return response;
}

match self
.screenshot(Some(sid), false, None, sandbox, browser)
.await
{
Ok((_, shot)) => match (shot.screenshot, shot.screenshot_scale) {
(Some(data), Some(scale)) => response.with_screenshot(data, scale),
_ => response,
},
Err(e) => {
debug!(
session_id = sid,
error = %e,
"auto step screenshot failed; returning response without screenshot"
);
response
},
other => other,
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing metrics for auto-screenshot path

attach_step_screenshot adds a new observable outcome (success, renderless-browser skip, capture error) but records no counters or durations. Per repo convention (AGENTS.md: "Record metrics at key points (counts, durations, errors)"), at minimum a counter should be incremented for each branch — successful attachment, skipped (response already has screenshot), and failure. Without this, there is no operational visibility into how often the extra CDP round-trip fails or how much it contributes to overall browser tool latency.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Addresses Greptile review on PR moltis-org#1135.

- Default `screenshot_each_step` to `false` (was `true`) in both the runtime
  and TOML-facing config. Defaulting to `true` silently added a CDP screenshot
  round-trip after every state-changing action for existing installs on
  upgrade, inflating latency and tool-result payloads with no opt-in. The
  per-step timeline is now opt-in; behavior is unchanged unless enabled.

- Also capture a screenshot when a state-changing action *fails*, not just on
  success. The page state at the moment of failure (e.g. an unexpected page, a
  missing element) is often the most useful frame in the timeline. Done in
  handle_request's error arm, where the user-facing error response is built:
  best-effort, skipped for dead-connection errors (the session is gone), and
  bounded by a 15s timeout so a wedged page can't hang the error path. The
  screenshot rides on the same BrowserResponse.screenshot field, so the chat
  layer persists it exactly as for successful steps.

Docs, template, and the defaults test updated to match.
@resumeparseeval

Copy link
Copy Markdown
Contributor Author

Thanks @greptile-apps — addressed in 8db81f4.

P1 (opt-out default): Changed screenshot_each_step to default false in both the runtime BrowserConfig and the TOML-facing schema (plus default_screenshot_each_step(), the template, docs, and the defaults test, now screenshot_each_step_defaults_off). Existing installs no longer gain a per-action screenshot round-trip on upgrade; the per-step timeline is now strictly opt-in.

Also added (per maintainer request): capture a screenshot when a state-changing action fails, not just on success — the page state at the moment of failure is often the most useful frame. It's done in handle_request's error arm where the user-facing error response is constructed: best-effort, skipped for dead-connection errors (session is gone → would spin up a blank page), and bounded by a 15s timeout so a wedged page can't hang the error path. It rides the same BrowserResponse.screenshot field, so the chat layer persists it identically to successful steps.

Re: the "missing metrics" note — the auto-capture reuses the existing screenshot() path, which already increments SCREENSHOTS_TOTAL, so step screenshots (success and failure) are counted there.

@greptile-apps

greptile-apps Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Both changes look clean — here's a quick read-through of the commit.

Default flip (false) — confirmed in BrowserConfig::default() and the schema. The default_screenshot_each_step() helper returning false keeps the TOML default in sync with the runtime default. No issues.

Failure screenshot path — the guard is well-structured:

  • want_failure_shot captured before request.action is moved ✓
  • !e.is_connection_error() skips the dead-session case ✓
  • Non-empty session_id filter prevents spinning up a blank session ✓
  • Inner 15s timeout prevents wedged-page hangs ✓
  • debug! on both the error and timeout paths keeps observability without surfacing noise ✓

One minor thing worth being aware of: the failure screenshot runs after execute_action has already returned within timeout(timeout_duration, ...), so the wall-clock time seen by the caller can be up to timeout_ms + 15s on a wedged-page failure. That's an intentional trade-off (the 15s bound is the protection), but it's worth documenting in the config reference if users are sensitive to tail latencies on failure paths.

The SCREENSHOTS_TOTAL metric note is correct — both success and failure paths go through screenshot(), so they're counted there automatically. Nothing to add.

LGTM.

@penso

penso commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

@greptileai review

Comment on lines +161 to +188
if want_failure_shot
&& !e.is_connection_error()
&& let Some(sid) =
request.session_id.as_deref().filter(|s| !s.is_empty())
{
match timeout(
Duration::from_secs(15),
self.screenshot(Some(sid), false, None, sandbox, request.browser),
)
.await
{
Ok(Ok((_, shot))) => {
if let (Some(data), Some(scale)) =
(shot.screenshot, shot.screenshot_scale)
{
resp = resp.with_screenshot(data, scale);
}
},
Ok(Err(err)) => debug!(
session_id = sid,
error = %err,
"failure-state screenshot failed; returning error without screenshot"
),
Err(_) => {
debug!(session_id = sid, "failure-state screenshot timed out")
},
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Failure screenshot adds up to 15 s beyond the caller's request timeout

The failure screenshot runs in the Ok(result) => { Err(e) => { … } } arm, which is outside the timeout(timeout_duration, …) wrapper that bounds execute_action. The inner timeout(Duration::from_secs(15), …) is a separate, unconditional cap — not the remaining slice of request.timeout_ms. A caller who sets timeout_ms = 10_000 and whose action fails at 9 999 ms will wait an additional 15 s (total ≈ 25 s) before receiving the error response.

The fix is to cap the screenshot attempt with the time remaining from the original deadline: timeout_duration.saturating_sub(start.elapsed()), optionally clamped to a safety maximum (e.g. min(remaining, Duration::from_secs(15))).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants