mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
feat(vom): update vom algorithm, fix overlay problem with background state machine
This commit is contained in:
@@ -68,6 +68,7 @@ Write operations only affect tabs in the **Agent Window** (or tabs you **borrowe
|
||||
```
|
||||
bsk navigate <url> --session <id>
|
||||
bsk snapshot --session <id> → aria tree with @e1, @e2, … refs
|
||||
bsk observe --session <id> → semantic VOM view; may reveal hover/focus surfaces
|
||||
bsk click @e3 --session <id> → or bsk fill, bsk select, bsk press
|
||||
bsk snapshot --session <id> → again after navigation / DOM change
|
||||
```
|
||||
@@ -78,11 +79,12 @@ Prefer `@eN` refs from the latest snapshot over raw CSS selectors. Use `--ref` /
|
||||
|
||||
## Observation priority
|
||||
|
||||
Start with `bsk snapshot` to understand page structure, text, controls, and element refs. Only escalate when the latest snapshot cannot answer the question:
|
||||
Start with `bsk snapshot` to understand page structure, text, controls, and element refs. Use `bsk observe` when semantic VOM output or conditional hover/focus surfaces would materially help. Only escalate to raw HTML or screenshots when the latest observation cannot answer the question:
|
||||
|
||||
1. `bsk snapshot` — default for page understanding and interaction planning
|
||||
2. `bsk get-html` — when hidden DOM, metadata, or markup details are required
|
||||
3. `bsk screenshot` — when visual layout, canvas/image content, or styling cannot be inferred from the snapshot. Use `--ref @eN` (from the latest snapshot) to crop to one element; omit `--ref` for the full visible tab.
|
||||
1. `bsk snapshot` — strict static page understanding and interaction planning
|
||||
2. `bsk observe` — semantic VOM observation; may run bounded perception probes such as hover-surface discovery
|
||||
3. `bsk get-html` — when hidden DOM, metadata, or markup details are required
|
||||
4. `bsk screenshot` — when visual layout, canvas/image content, or styling cannot be inferred from the observation. Use `--ref @eN` (from the latest snapshot/observe) to crop to one element; omit `--ref` for the full visible tab.
|
||||
|
||||
Do **not** call `bsk get-html` or `bsk screenshot` first just to inspect a page.
|
||||
|
||||
@@ -141,7 +143,8 @@ Details and flags: **`bsk <cmd> --help`**
|
||||
|
||||
| Command | Summary |
|
||||
|---------|---------|
|
||||
| `bsk snapshot` | First-choice page understanding: accessibility tree with `@eN` element refs |
|
||||
| `bsk snapshot` | First-choice static page understanding: accessibility tree with `@eN` element refs |
|
||||
| `bsk observe` | Semantic VOM observation with bounded perception probes for conditional surfaces |
|
||||
| `bsk get-html` | Raw HTML dump after snapshot is insufficient (high token cost) |
|
||||
| `bsk screenshot` | PNG capture after snapshot is insufficient: full visible tab, or `--ref @eN` to crop to one element (`--out` path optional) |
|
||||
|
||||
@@ -260,7 +263,7 @@ Always **`bsk session stop <id>`** in a `finally`-style path so the Agent Window
|
||||
2. **No long borrow** — do not leave a user's personal tab in the Agent Window across unrelated tasks.
|
||||
3. **No skip stop** — always `bsk session stop <id>`; never assume idle timeout will clean up.
|
||||
4. **No post-success control** — once the user’s goal (or last trace step) is met, do not keep operating the page; stop the session unless they asked to keep it open.
|
||||
5. **No observe escalation before snapshot** — use `bsk snapshot` first; only use `bsk get-html` or `bsk screenshot` when the snapshot is insufficient. Element screenshots (`--ref @eN`) still require a fresh snapshot ref — never skip snapshot just to grab a visual.
|
||||
5. **No raw observe escalation before snapshot/observe** — use `bsk snapshot` first; use `bsk observe` when VOM semantics or conditional surfaces help. Only use `bsk get-html` or `bsk screenshot` when snapshot/observe is insufficient. Element screenshots (`--ref @eN`) still require a fresh snapshot/observe ref — never skip observation just to grab a visual.
|
||||
6. **`evaluate` is powerful and risky** — use only when snapshot + click/fill/select cannot suffice; never on credential surfaces.
|
||||
|
||||
---
|
||||
|
||||
@@ -19,6 +19,7 @@ pub mod interaction;
|
||||
pub mod logs;
|
||||
pub mod navigate;
|
||||
pub mod network;
|
||||
pub mod observe;
|
||||
pub mod record;
|
||||
pub mod record_state;
|
||||
pub mod render_error;
|
||||
@@ -41,6 +42,7 @@ use crate::cli::install_skill::InstallSkillArgs;
|
||||
use crate::cli::interaction::{ClickArgs, FillArgs, PressArgs, SelectArgs};
|
||||
use crate::cli::navigate::{NavigateCommand, NavigateHistoryArgs, ReloadArgs};
|
||||
use crate::cli::network::NetworkArgs;
|
||||
use crate::cli::observe::ObserveArgs;
|
||||
use crate::cli::record::RecordCmd;
|
||||
use crate::cli::screenshot::ScreenshotArgs;
|
||||
use crate::cli::session::SessionCmd;
|
||||
@@ -122,6 +124,9 @@ pub enum Command {
|
||||
/// Produce an indented aria-snapshot with @eN refs.
|
||||
Snapshot(SnapshotArgs),
|
||||
|
||||
/// Produce a semantic VOM observation with perception probes.
|
||||
Observe(ObserveArgs),
|
||||
|
||||
/// Read buffered console/log/exception messages.
|
||||
Console(ConsoleArgs),
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
//! `bsk observe` — semantic VOM observation with bounded perception probes.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Context;
|
||||
use bsk_protocol::Method;
|
||||
use bsk_protocol::tools::{ObserveParams, ObserveResult};
|
||||
use clap::Args;
|
||||
|
||||
use crate::cli::TOOL_IPC_TIMEOUT;
|
||||
use crate::cli::dialogs::print_dialog_summaries;
|
||||
use crate::cli::ensure_daemon::ensure_daemon;
|
||||
use crate::cli::error::{CliError, Format};
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct ObserveArgs {
|
||||
/// Session id (must be active).
|
||||
#[arg(long)]
|
||||
pub session: String,
|
||||
|
||||
/// Target tab. Defaults to the Agent Window's active tab.
|
||||
#[arg(long = "tab-id")]
|
||||
pub tab_id: Option<i64>,
|
||||
|
||||
/// Cap on VOM tree depth before truncating.
|
||||
#[arg(long = "max-depth")]
|
||||
pub max_depth: Option<u32>,
|
||||
|
||||
/// Soft cap on rendered tokens (~4 chars/token).
|
||||
#[arg(long = "max-tokens")]
|
||||
pub max_tokens: Option<u32>,
|
||||
}
|
||||
|
||||
pub fn dispatch(args: ObserveArgs, format: Format) -> Result<(), CliError> {
|
||||
let info = ensure_daemon().context("ensure daemon is running")?;
|
||||
run(info.sock_path, args, format)
|
||||
}
|
||||
|
||||
fn run(sock: PathBuf, args: ObserveArgs, format: Format) -> Result<(), CliError> {
|
||||
let params = ObserveParams {
|
||||
session_id: args.session.clone(),
|
||||
tab_id: args.tab_id,
|
||||
max_depth: args.max_depth,
|
||||
max_tokens: args.max_tokens,
|
||||
};
|
||||
let reply: ObserveResult = call(sock, params)?;
|
||||
match format {
|
||||
Format::Json => {
|
||||
let json = serde_json::to_string_pretty(&reply)
|
||||
.map_err(|e| CliError::Local(anyhow::anyhow!(e)))?;
|
||||
println!("{json}");
|
||||
}
|
||||
Format::Human => {
|
||||
if reply.text.is_empty() {
|
||||
println!("(empty observation — page may still be loading)");
|
||||
} else {
|
||||
println!("{}", reply.text);
|
||||
}
|
||||
if reply.truncated {
|
||||
eprintln!(
|
||||
"warning: observation truncated (refs={}, tab={}). Increase --max-depth / --max-tokens if needed.",
|
||||
reply.ref_count, reply.tab_id
|
||||
);
|
||||
}
|
||||
print_dialog_summaries(&reply.dialogs);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn call(sock: PathBuf, params: ObserveParams) -> Result<ObserveResult, CliError> {
|
||||
crate::cli::business_rpc::call::<ObserveParams, ObserveResult>(
|
||||
sock,
|
||||
"observe",
|
||||
Method::ToolObserve,
|
||||
Some(params),
|
||||
TOOL_IPC_TIMEOUT,
|
||||
)
|
||||
}
|
||||
@@ -231,6 +231,7 @@ pub fn full_handler(status: DaemonStatus, state: Arc<DaemonState>) -> RpcHandler
|
||||
| Method::ToolConsole
|
||||
| Method::ToolNetwork
|
||||
| Method::ToolSnapshot
|
||||
| Method::ToolObserve
|
||||
| Method::ToolGetHtml
|
||||
| Method::ToolNavigate
|
||||
| Method::ToolNavigateBack
|
||||
@@ -291,16 +292,14 @@ async fn handle_tool_dispatch(
|
||||
};
|
||||
// Pre-flight: if the user has clicked the agent-window mask's
|
||||
// stop button, every session carries a one-shot "pending
|
||||
// interrupt" marker. The marker is consumed by the next
|
||||
// *mutating* tool call (which is rejected with `UserAborted`).
|
||||
// Read-only tools (snapshot / get_html / waits / tab_list) and
|
||||
// session-lifecycle RPCs pass through transparently — gating
|
||||
// them would prevent the agent from observing page state
|
||||
// before asking the user, or from cleanly tearing down the
|
||||
// session. Classification lives on `Method::is_mutating()` so
|
||||
// adding a new tool variant requires an explicit
|
||||
// classification call.
|
||||
if method.is_mutating() && state.session_interrupts.try_consume(&session_id) {
|
||||
// interrupt" marker. The marker is consumed by the next method
|
||||
// that dispatches browser/page input (which is rejected with
|
||||
// `UserAborted`). Passive reads and control-plane RPCs pass through
|
||||
// transparently — gating them would prevent the agent from observing
|
||||
// page state before asking the user, or from cleanly tearing down the
|
||||
// session. Classification lives on `Method::effect()` so adding a new
|
||||
// tool variant requires an explicit classification call.
|
||||
if method.requires_interrupt_gate() && state.session_interrupts.try_consume(&session_id) {
|
||||
return ResponseBody::Err(RpcError {
|
||||
code: ErrorCode::UserAborted,
|
||||
message: "tool dispatch rejected: pending user interrupt. The user explicitly requested to stop. Ask the user how to proceed before issuing further actions.".into(),
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
//!
|
||||
//! Holds a single-use marker per `SessionId` indicating that the
|
||||
//! user has clicked the agent-window mask's stop button. The next
|
||||
//! mutating `tool.*` call for that session is rejected with
|
||||
//! `ErrorCode::UserAborted`; read-only tools and session-lifecycle
|
||||
//! browser-input-dispatching `tool.*` call for that session is rejected
|
||||
//! with `ErrorCode::UserAborted`; passive reads and session-lifecycle
|
||||
//! RPCs pass through transparently and do not consume the marker.
|
||||
//!
|
||||
//! The marker is single-use and has no expiry: it sits in the
|
||||
//! registry until consumed by a mutating call or until the session
|
||||
//! is torn down. This lets the user's interrupt survive an LLM
|
||||
//! registry until consumed by an input-dispatching call or until the
|
||||
//! session is torn down. This lets the user's interrupt survive an LLM
|
||||
//! thinking phase of arbitrary length — the v1 time-window
|
||||
//! mechanism dropped interrupts whenever the LLM took longer to
|
||||
//! respond than the window allowed.
|
||||
|
||||
@@ -46,8 +46,8 @@ pub struct DaemonState {
|
||||
/// Per-session "pending interrupt" signal. The WS event handler
|
||||
/// `mark`s the session when the user clicks the agent-window
|
||||
/// mask's stop button; the IPC tool-dispatch handler
|
||||
/// `try_consume`s on the way in so the next mutating tool call
|
||||
/// is rejected with `UserAborted`. Independent of
|
||||
/// `try_consume`s on the way in so the next browser-input-dispatching
|
||||
/// tool call is rejected with `UserAborted`. Independent of
|
||||
/// `SessionRegistry` because the signal is a transient runtime
|
||||
/// control state.
|
||||
pub session_interrupts: Arc<SessionInterruptRegistry>,
|
||||
|
||||
@@ -82,6 +82,7 @@ fn dispatch(cli: Cli, format: Format) -> Result<(), CliError> {
|
||||
Command::Tab(cmd) => cli::tab::dispatch(cmd, format),
|
||||
Command::Screenshot(args) => cli::screenshot::dispatch(args, format),
|
||||
Command::Snapshot(args) => cli::snapshot::dispatch(args, format),
|
||||
Command::Observe(args) => cli::observe::dispatch(args, format),
|
||||
Command::Console(args) => cli::console::dispatch(args, format),
|
||||
Command::Network(args) => cli::network::dispatch(args, format),
|
||||
Command::GetHtml(args) => cli::get_html::dispatch(args, format),
|
||||
|
||||
@@ -283,11 +283,11 @@ async fn session_user_interrupt_event_cancels_inflight_with_user_aborted() {
|
||||
handle.shutdown().await;
|
||||
}
|
||||
|
||||
/// The pending-interrupt marker must reject a *new* mutating tool
|
||||
/// The pending-interrupt marker must reject a *new* browser-input tool
|
||||
/// call that arrives AFTER the user clicked stop, even when no
|
||||
/// tool was inflight at click time. Under v2 single-use marker
|
||||
/// semantics, the marker survives indefinitely until consumed by
|
||||
/// a mutating tool call — there is no time window. This is the
|
||||
/// an input-dispatching tool call — there is no time window. This is the
|
||||
/// core motivating scenario: agents spend most of their time
|
||||
/// between tool calls (LLM thinking), and the stop button must
|
||||
/// catch a tool dispatched arbitrarily later.
|
||||
@@ -426,12 +426,12 @@ async fn read_only_tool_passes_through_without_consuming_interrupt_marker() {
|
||||
let ws_sink = Arc::new(tokio::sync::Mutex::new(ws_sink));
|
||||
|
||||
// Fake extension: replies to session_start, console, and snapshot
|
||||
// (read-only tools must succeed transparently). Records click count
|
||||
// — must remain 0 because the daemon should reject the click
|
||||
// (passive reads must succeed transparently). Records observe count
|
||||
// — must remain 0 because the daemon should reject transient input
|
||||
// without forwarding.
|
||||
let ws_sink_for_responder = Arc::clone(&ws_sink);
|
||||
let observed_click_count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let observed_click_count_clone = Arc::clone(&observed_click_count);
|
||||
let observed_observe_count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let observed_observe_count_clone = Arc::clone(&observed_observe_count);
|
||||
let responder = tokio::spawn(async move {
|
||||
let mut ws_stream = ws_stream;
|
||||
while let Some(Ok(msg)) = ws_stream.next().await {
|
||||
@@ -458,8 +458,8 @@ async fn read_only_tool_passes_through_without_consuming_interrupt_marker() {
|
||||
"truncated": false
|
||||
})),
|
||||
Method::ToolSnapshot => ResponseBody::Ok(json!({"ok": true})),
|
||||
Method::ToolClick => {
|
||||
observed_click_count_clone
|
||||
Method::ToolObserve => {
|
||||
observed_observe_count_clone
|
||||
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
continue;
|
||||
}
|
||||
@@ -547,26 +547,25 @@ async fn read_only_tool_passes_through_without_consuming_interrupt_marker() {
|
||||
snapshot_outcome
|
||||
);
|
||||
|
||||
// The mutating click that follows MUST still be rejected — the
|
||||
// marker survived the snapshot.
|
||||
let click_outcome = ipc
|
||||
// The transient-input observe that follows MUST still be rejected — the
|
||||
// marker survived the passive reads.
|
||||
let observe_outcome = ipc
|
||||
.call::<_, serde_json::Value>(
|
||||
"click-after-snapshot",
|
||||
Method::ToolClick,
|
||||
"observe-after-snapshot",
|
||||
Method::ToolObserve,
|
||||
Some(json!({
|
||||
"session_id": start.session_id,
|
||||
"ref": "fake-ref-2",
|
||||
})),
|
||||
Duration::from_secs(3),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let err = click_outcome.expect_err("tool.click must be rejected");
|
||||
let err = observe_outcome.expect_err("tool.observe must be rejected");
|
||||
assert_eq!(err.code, ErrorCode::UserAborted, "got {:?}", err);
|
||||
assert_eq!(
|
||||
observed_click_count.load(std::sync::atomic::Ordering::SeqCst),
|
||||
observed_observe_count.load(std::sync::atomic::Ordering::SeqCst),
|
||||
0,
|
||||
"tool.click must NOT have been forwarded after snapshot transparency"
|
||||
"tool.observe must NOT have been forwarded after passive-read transparency"
|
||||
);
|
||||
|
||||
responder.abort();
|
||||
@@ -574,7 +573,7 @@ async fn read_only_tool_passes_through_without_consuming_interrupt_marker() {
|
||||
}
|
||||
|
||||
/// The pending-interrupt marker must survive an arbitrary delay
|
||||
/// between user click and next mutating tool call. This is the
|
||||
/// between user click and next browser-input tool call. This is the
|
||||
/// regression test for v2's core motivating bug: v1's 500ms time
|
||||
/// window dropped interrupts whenever the LLM's thinking phase
|
||||
/// took longer to respond than the window allowed.
|
||||
|
||||
@@ -11,9 +11,9 @@ use bsk::ipc_client::IpcClient;
|
||||
use bsk_protocol::system::{HandshakeParams, HandshakeResult};
|
||||
use bsk_protocol::tools::{
|
||||
ConsoleEntry, ConsoleEntryKind, ConsoleParams, ConsoleResult, GetHtmlParams, GetHtmlResult,
|
||||
NetworkEntry, NetworkEntryKind, NetworkParams, NetworkResult, ScreenshotParams,
|
||||
ScreenshotResult, SessionStartParams, SessionStartResult, SnapshotParams, SnapshotResult,
|
||||
TabInfo, TabListParams, TabListResult, TabScope,
|
||||
NetworkEntry, NetworkEntryKind, NetworkParams, NetworkResult, ObserveParams, ObserveResult,
|
||||
ScreenshotParams, ScreenshotResult, SessionStartParams, SessionStartResult, SnapshotParams,
|
||||
SnapshotResult, TabInfo, TabListParams, TabListResult, TabScope,
|
||||
};
|
||||
use bsk_protocol::{
|
||||
BrowserPeerInfo, ErrorCode, Frame, Method, RequestFrame, ResponseBody, ResponseFrame, RpcError,
|
||||
@@ -462,6 +462,46 @@ async fn snapshot_returns_text_and_ref_count() {
|
||||
handle.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn observe_returns_semantic_text_and_ref_count() {
|
||||
let (handle, sock) = spawn_daemon().await;
|
||||
let mut ws = connect_ext(handle.ws_addr()).await;
|
||||
let _ = do_handshake(&mut ws).await;
|
||||
|
||||
run_extension(ws, |req| {
|
||||
assert_eq!(req.method, Method::ToolObserve);
|
||||
let _: ObserveParams = serde_json::from_value(req.params.clone().unwrap()).unwrap();
|
||||
ResponseBody::Ok(
|
||||
serde_json::to_value(ObserveResult {
|
||||
text: "@vom 1\n @e1 button \"Products\" [hover: Shoes]\n".into(),
|
||||
ref_count: 1,
|
||||
tab_id: 13,
|
||||
truncated: false,
|
||||
dialogs: vec![],
|
||||
})
|
||||
.unwrap(),
|
||||
)
|
||||
});
|
||||
|
||||
let session_id = ipc_session_start(&sock).await;
|
||||
let result: ObserveResult = ipc_tool_call(
|
||||
&sock,
|
||||
Method::ToolObserve,
|
||||
ObserveParams {
|
||||
session_id,
|
||||
tab_id: None,
|
||||
max_depth: None,
|
||||
max_tokens: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("observe ok");
|
||||
assert_eq!(result.ref_count, 1);
|
||||
assert!(result.text.contains("[hover: Shoes]"));
|
||||
assert_eq!(result.tab_id, 13);
|
||||
handle.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn get_html_round_trips_with_ref_alias() {
|
||||
let (handle, sock) = spawn_daemon().await;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "ObserveParams",
|
||||
"description": "Parameters for `tool.observe` — produce a semantic VOM observation for one tab plus a fresh `@e<N>` ref-store on the extension side.\n\nUnlike `tool.snapshot`, this path may run bounded perception probes such as hover-surface discovery. It must not submit input, click, navigate, or otherwise commit page state.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"session_id"
|
||||
],
|
||||
"properties": {
|
||||
"max_depth": {
|
||||
"description": "Cap the depth of the rendered VOM tree.",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "uint32",
|
||||
"minimum": 0.0
|
||||
},
|
||||
"max_tokens": {
|
||||
"description": "Soft cap on rendered tokens (approximate; extension uses a best-effort heuristic based on character count).",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "uint32",
|
||||
"minimum": 0.0
|
||||
},
|
||||
"session_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"tab_id": {
|
||||
"description": "Optional target tab. Defaults to the Agent Window's currently active tab.",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int64"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "ObserveResult",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ref_count",
|
||||
"tab_id",
|
||||
"text"
|
||||
],
|
||||
"properties": {
|
||||
"dialogs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/JavaScriptDialogInfo"
|
||||
}
|
||||
},
|
||||
"ref_count": {
|
||||
"description": "Number of `@e<N>` refs registered for this session by this observation. Equivalent to the size of the new ref-store map.",
|
||||
"type": "integer",
|
||||
"format": "uint32",
|
||||
"minimum": 0.0
|
||||
},
|
||||
"tab_id": {
|
||||
"description": "Tab the observation was computed for.",
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"text": {
|
||||
"description": "Semantic VOM observation text. Refs are rendered as `@e<N>` so the agent can copy them into subsequent interaction tools.",
|
||||
"type": "string"
|
||||
},
|
||||
"truncated": {
|
||||
"description": "Whether the rendered tree was truncated because of `max_depth` / `max_tokens`. Agents may re-run with looser caps.",
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"JavaScriptDialogHandledAction": {
|
||||
"description": "How the extension resolved the dialog so CDP could continue.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"accepted",
|
||||
"dismissed"
|
||||
]
|
||||
},
|
||||
"JavaScriptDialogInfo": {
|
||||
"description": "One observed + handled JavaScript dialog during a tool call.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"handled",
|
||||
"message",
|
||||
"sequence",
|
||||
"tab_id",
|
||||
"type"
|
||||
],
|
||||
"properties": {
|
||||
"default_prompt": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"handled": {
|
||||
"$ref": "#/definitions/JavaScriptDialogHandledAction"
|
||||
},
|
||||
"has_browser_handler": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"sequence": {
|
||||
"description": "Monotonic per-tab sequence for ordering within a session.",
|
||||
"type": "integer",
|
||||
"format": "uint64",
|
||||
"minimum": 0.0
|
||||
},
|
||||
"tab_id": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"type": {
|
||||
"$ref": "#/definitions/JavaScriptDialogType"
|
||||
},
|
||||
"url": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"JavaScriptDialogType": {
|
||||
"description": "Native JS dialog kind reported by CDP.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"alert",
|
||||
"confirm",
|
||||
"prompt",
|
||||
"beforeunload"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,8 @@ fn main() {
|
||||
|
||||
dump!(SnapshotParams, "tool_snapshot_params");
|
||||
dump!(SnapshotResult, "tool_snapshot_result");
|
||||
dump!(ObserveParams, "tool_observe_params");
|
||||
dump!(ObserveResult, "tool_observe_result");
|
||||
dump!(GetHtmlParams, "tool_get_html_params");
|
||||
dump!(GetHtmlResult, "tool_get_html_result");
|
||||
dump!(ScreenshotParams, "tool_screenshot_params");
|
||||
|
||||
@@ -2,6 +2,22 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Observable browser-side effect class for a protocol method.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MethodEffect {
|
||||
/// Reads browser/session state without dispatching page input.
|
||||
PassiveRead,
|
||||
/// Dispatches temporary input such as hover probes. It must not submit,
|
||||
/// navigate, or persist page state, but it can trigger page event handlers.
|
||||
TransientInput,
|
||||
/// Drives browser/page state such as clicking, filling, navigation, tabs,
|
||||
/// or arbitrary page script.
|
||||
BrowserMutation,
|
||||
/// Control-plane/session lifecycle operation. These are deliberately not
|
||||
/// gated by the pending browser-action interrupt path.
|
||||
ControlPlane,
|
||||
}
|
||||
|
||||
/// Namespaced method string (`system.handshake`, `tool.tab_list`, …).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum Method {
|
||||
@@ -58,6 +74,8 @@ pub enum Method {
|
||||
ToolSelect,
|
||||
#[serde(rename = "tool.snapshot")]
|
||||
ToolSnapshot,
|
||||
#[serde(rename = "tool.observe")]
|
||||
ToolObserve,
|
||||
#[serde(rename = "tool.get_html")]
|
||||
ToolGetHtml,
|
||||
#[serde(rename = "tool.screenshot")]
|
||||
@@ -86,13 +104,13 @@ pub enum Method {
|
||||
}
|
||||
|
||||
impl Method {
|
||||
/// Whether this RPC may modify browser state.
|
||||
/// Browser-side effect classification for this method.
|
||||
///
|
||||
/// Used by the daemon's pending-interrupt machinery: when the
|
||||
/// user has clicked the agent-window mask's stop button, the
|
||||
/// next *mutating* tool call for that session is rejected with
|
||||
/// `ErrorCode::UserAborted`. Read-only tools and session-
|
||||
/// lifecycle RPCs pass through transparently.
|
||||
/// next tool call that dispatches browser/page input for that
|
||||
/// session is rejected with `ErrorCode::UserAborted`. Passive reads
|
||||
/// and control-plane RPCs pass through transparently.
|
||||
///
|
||||
/// **Compile-time enforcement.** The match below is exhaustive
|
||||
/// (no `_ =>` fallthrough). Adding a new `Method` variant is a
|
||||
@@ -101,9 +119,12 @@ impl Method {
|
||||
///
|
||||
/// **Judgment calls** (read these before adding new variants):
|
||||
///
|
||||
/// * `tool.evaluate` is classified as mutating because the
|
||||
/// * `tool.evaluate` is classified as browser-mutating because the
|
||||
/// daemon cannot statically distinguish a `document.title`
|
||||
/// read from a `form.submit()` write.
|
||||
/// * `tool.observe` is classified as transient input: its bounded
|
||||
/// hover probes do not commit browser state, but they dispatch real
|
||||
/// page input events and therefore must be gated like automation.
|
||||
/// * `tool.wait_*` are classified as read-only: they do not
|
||||
/// initiate any browser action; they observe state only.
|
||||
/// * `session.*` and `tool.session_*` are NOT gated. Blocking
|
||||
@@ -111,9 +132,9 @@ impl Method {
|
||||
/// tearing down after observing the user's interrupt.
|
||||
/// * `cancel` is NOT gated. It's a control-plane operation
|
||||
/// (stops another in-flight RPC), not a browser action.
|
||||
pub fn is_mutating(&self) -> bool {
|
||||
pub fn effect(&self) -> MethodEffect {
|
||||
match self {
|
||||
// Mutating tool calls — gated by pending-interrupt.
|
||||
// Browser/page mutations — gated by pending-interrupt.
|
||||
Method::ToolTabCreate
|
||||
| Method::ToolTabClose
|
||||
| Method::ToolTabBorrow
|
||||
@@ -130,10 +151,13 @@ impl Method {
|
||||
| Method::ToolEvaluate
|
||||
// May navigate via optional `url` and changes Agent Window
|
||||
// chrome; gate behind pending-interrupt like other writes.
|
||||
| Method::ToolRecordStart => true,
|
||||
| Method::ToolRecordStart => MethodEffect::BrowserMutation,
|
||||
|
||||
// Read-only tool calls — transparent.
|
||||
//
|
||||
// Transient input — no committed browser action, but still page
|
||||
// input. It must be stopped by pending user interrupts.
|
||||
Method::ToolObserve => MethodEffect::TransientInput,
|
||||
|
||||
// Passive reads — transparent.
|
||||
// `record_stop` / `record_await` observe / finish a recording
|
||||
// without driving new automation gestures, so they stay
|
||||
// ungated (teardown after interrupt must still work).
|
||||
@@ -147,7 +171,7 @@ impl Method {
|
||||
| Method::ToolWaitMs
|
||||
| Method::ToolRequestHelp
|
||||
| Method::ToolRecordStop
|
||||
| Method::ToolRecordAwait => false,
|
||||
| Method::ToolRecordAwait => MethodEffect::PassiveRead,
|
||||
|
||||
// Session lifecycle — not gated.
|
||||
Method::SessionStart
|
||||
@@ -155,16 +179,29 @@ impl Method {
|
||||
| Method::SessionStopAll
|
||||
| Method::SessionList
|
||||
| Method::ToolSessionStart
|
||||
| Method::ToolSessionStop => false,
|
||||
| Method::ToolSessionStop => MethodEffect::ControlPlane,
|
||||
|
||||
// System / control — not gated.
|
||||
Method::SystemHandshake
|
||||
| Method::SystemPing
|
||||
| Method::SystemStatus
|
||||
| Method::BrowserList
|
||||
| Method::Cancel => false,
|
||||
| Method::Cancel => MethodEffect::ControlPlane,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this RPC drives browser/page state in the traditional sense.
|
||||
pub fn is_mutating(&self) -> bool {
|
||||
self.effect() == MethodEffect::BrowserMutation
|
||||
}
|
||||
|
||||
/// Whether a pending user interrupt should reject this method.
|
||||
pub fn requires_interrupt_gate(&self) -> bool {
|
||||
matches!(
|
||||
self.effect(),
|
||||
MethodEffect::TransientInput | MethodEffect::BrowserMutation
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -209,6 +246,7 @@ mod tests {
|
||||
fn is_mutating_classifies_read_only_tools_as_non_mutating() {
|
||||
assert!(!Method::ToolTabList.is_mutating());
|
||||
assert!(!Method::ToolSnapshot.is_mutating());
|
||||
assert!(!Method::ToolObserve.is_mutating());
|
||||
assert!(!Method::ToolGetHtml.is_mutating());
|
||||
assert!(!Method::ToolScreenshot.is_mutating());
|
||||
assert!(!Method::ToolConsole.is_mutating());
|
||||
@@ -264,4 +302,20 @@ mod tests {
|
||||
assert!(!Method::BrowserList.is_mutating());
|
||||
assert!(!Method::Cancel.is_mutating());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_classifies_observe_as_transient_input() {
|
||||
assert_eq!(Method::ToolSnapshot.effect(), MethodEffect::PassiveRead);
|
||||
assert_eq!(Method::ToolObserve.effect(), MethodEffect::TransientInput);
|
||||
assert_eq!(Method::ToolClick.effect(), MethodEffect::BrowserMutation);
|
||||
assert_eq!(Method::Cancel.effect(), MethodEffect::ControlPlane);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interrupt_gate_includes_transient_input() {
|
||||
assert!(!Method::ToolSnapshot.requires_interrupt_gate());
|
||||
assert!(Method::ToolObserve.requires_interrupt_gate());
|
||||
assert!(Method::ToolClick.requires_interrupt_gate());
|
||||
assert!(!Method::Cancel.requires_interrupt_gate());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Read-only observation tools (`tool.snapshot`, `tool.get_html`, `tool.screenshot`).
|
||||
//! Observation tools (`tool.snapshot`, `tool.observe`, `tool.get_html`, `tool.screenshot`).
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -50,6 +50,50 @@ pub struct SnapshotResult {
|
||||
pub dialogs: Vec<JavaScriptDialogInfo>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// observe
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parameters for `tool.observe` — produce a semantic VOM observation
|
||||
/// for one tab plus a fresh `@e<N>` ref-store on the extension side.
|
||||
///
|
||||
/// Unlike `tool.snapshot`, this path may run bounded perception probes
|
||||
/// such as hover-surface discovery. It must not submit input, click,
|
||||
/// navigate, or otherwise commit page state.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct ObserveParams {
|
||||
pub session_id: String,
|
||||
/// Optional target tab. Defaults to the Agent Window's currently
|
||||
/// active tab.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tab_id: Option<i64>,
|
||||
/// Cap the depth of the rendered VOM tree.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_depth: Option<u32>,
|
||||
/// Soft cap on rendered tokens (approximate; extension uses a
|
||||
/// best-effort heuristic based on character count).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_tokens: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct ObserveResult {
|
||||
/// Semantic VOM observation text. Refs are rendered as `@e<N>` so
|
||||
/// the agent can copy them into subsequent interaction tools.
|
||||
pub text: String,
|
||||
/// Number of `@e<N>` refs registered for this session by this
|
||||
/// observation. Equivalent to the size of the new ref-store map.
|
||||
pub ref_count: u32,
|
||||
/// Tab the observation was computed for.
|
||||
pub tab_id: i64,
|
||||
/// Whether the rendered tree was truncated because of `max_depth` /
|
||||
/// `max_tokens`. Agents may re-run with looser caps.
|
||||
#[serde(default)]
|
||||
pub truncated: bool,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub dialogs: Vec<JavaScriptDialogInfo>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// get_html
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -227,4 +271,19 @@ mod tests {
|
||||
let round: ScreenshotResult = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(round, r);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observe_result_round_trips_with_ref_count() {
|
||||
let r = ObserveResult {
|
||||
text: "@vom 1\n @e1 button \"submit\"\n".into(),
|
||||
ref_count: 1,
|
||||
tab_id: 42,
|
||||
truncated: false,
|
||||
dialogs: Vec::new(),
|
||||
};
|
||||
let v = serde_json::to_value(&r).unwrap();
|
||||
assert_eq!(v.get("ref_count").and_then(|v| v.as_u64()), Some(1));
|
||||
let round: ObserveResult = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(round, r);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user