feat(vom): update vom algorithm, fix overlay problem with background state machine

This commit is contained in:
Ljy-0827
2026-07-27 13:46:33 +08:00
parent e14ae9cea0
commit 4fb2e331a6
36 changed files with 2276 additions and 452 deletions
+9 -6
View File
@@ -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.
---
+5
View File
@@ -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),
+79
View File
@@ -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,
)
}
+9 -10
View File
@@ -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.
+2 -2
View File
@@ -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>,
+1
View File
@@ -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),
+17 -18
View File
@@ -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.
+43 -3
View File
@@ -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;