Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/content/docs/reference/cua-driver/mcp-tools.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -476,8 +476,8 @@ Legacy browser compatibility tool. Prefer get_browser_state and the typed browse

Actions:
- execute_javascript: Run JS and return the result.
- get_text: Extract visible text from the page.
- query_dom: Find elements matching a CSS selector.
- get_text: Extract visible text from the page. On macOS, structuredContent.source identifies javascript, cdp, or ax_fallback; a successful AX fallback preserves the triggering error and truncation state in structuredContent.
- query_dom: Find elements matching a CSS selector, with the same macOS fallback metadata contract as get_text.
- click_element: Click a CSS-selected element AND animate the agent cursor to its on-screen center first (so the user sees what the agent is doing). Prefer over `execute_javascript('el.click()')` whenever you want visible cursor feedback.
- insert_text: Insert `text` at whatever currently holds DOM focus in one native operation (CDP Input.insertText) — no synthesized key events, but more durable than a one-shot execute_javascript write since rich-text editors already have to treat it like an IME commit. Try this before type_keystrokes on a contenteditable that discarded an execute_javascript write. Click/focus the target field first.
- type_keystrokes: Type `text` via real per-character keystroke events into whatever currently holds DOM focus. Slower than insert_text but the most durable rung — use it when insert_text also gets discarded, or the editor's own keydown/keyup handlers need to see real keys. Click/focus the target field first.
Expand Down
8 changes: 8 additions & 0 deletions libs/cua-driver/rust/Skills/cua-driver/BROWSER.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,14 @@ operator can enable the temporary compatibility path with
Driver after changing the flag. It does not add typed endpoint ownership,
capabilities, or existing-profile consent.

On macOS, legacy `page.get_text` and `page.query_dom` keep their original text
payload but also report read provenance in `structuredContent.source`:
`javascript`, `cdp`, or `ax_fallback`. When JavaScript or CDP fails and the AX
fallback succeeds, `structuredContent.fallback_error` preserves the triggering
error and `structuredContent.truncated` reports whether the bounded AX walk was
cut short. A direct WKWebView/Tauri AX read has no `fallback_error` because no
JavaScript or CDP route was attempted.

## Support boundaries

| Surface | Typed state and mutation | Important boundary |
Expand Down
163 changes: 157 additions & 6 deletions libs/cua-driver/rust/crates/cua-driver-core/src/page.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,54 @@ pub struct ClickElementResult {
pub message: String,
}

/// Provenance for a successful legacy page read. macOS uses this to make its
/// JavaScript/CDP-to-AX compatibility fallback explicit without changing the
/// existing textual response.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PageReadSource {
Javascript,
Cdp,
AxFallback,
}

/// Structured metadata attached to a legacy page read when the backend can
/// identify its modality.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PageReadMetadata {
pub source: PageReadSource,
#[serde(skip_serializing_if = "Option::is_none")]
pub fallback_error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub truncated: Option<bool>,
}

/// Compatibility result for `get_text` and `query_dom`. `content` remains the
/// exact legacy text payload; metadata is emitted separately as MCP structured
/// content. Backends without modality metadata can keep implementing the
/// original string-returning methods.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PageReadResult {
pub content: String,
pub metadata: Option<PageReadMetadata>,
}

impl PageReadResult {
pub fn text(content: String) -> Self {
Self {
content,
metadata: None,
}
}

pub fn with_metadata(content: String, metadata: PageReadMetadata) -> Self {
Self {
content,
metadata: Some(metadata),
}
}
}

/// Platform-specific backend for the `page` tool.
///
/// All methods take `(pid, window_id)` for consistency across actions.
Expand All @@ -75,6 +123,14 @@ pub trait PageBackend: Send + Sync {
/// `document.body.innerText`).
async fn get_text(&self, pid: i32, window_id: u64) -> anyhow::Result<String>;

/// Metadata-aware form used by the shared dispatcher. The default keeps
/// existing platform backends source-compatible and returns plain text.
async fn get_text_result(&self, pid: i32, window_id: u64) -> anyhow::Result<PageReadResult> {
self.get_text(pid, window_id)
.await
.map(PageReadResult::text)
}

/// Find elements matching `css_selector` and return a formatted-text
/// response (same human-readable shape macOS already emits).
async fn query_dom(
Expand All @@ -85,6 +141,20 @@ pub trait PageBackend: Send + Sync {
attributes: &[String],
) -> anyhow::Result<String>;

/// Metadata-aware form used by the shared dispatcher. See
/// [`PageBackend::get_text_result`].
async fn query_dom_result(
&self,
pid: i32,
window_id: u64,
css_selector: &str,
attributes: &[String],
) -> anyhow::Result<PageReadResult> {
self.query_dom(pid, window_id, css_selector, attributes)
.await
.map(PageReadResult::text)
}

/// Evaluate `javascript` against the page and return the stringified
/// result. Backends without a JS path should return an actionable error.
async fn execute_javascript(
Expand Down Expand Up @@ -266,8 +336,11 @@ fn def() -> &'static ToolDef {
--remote-debugging-port is set), and WKWebView/Tauri/AT-SPI fallbacks.\n\n\
Actions:\n\
- execute_javascript: Run JS and return the result.\n\
- get_text: Extract visible text from the page.\n\
- query_dom: Find elements matching a CSS selector.\n\
- get_text: Extract visible text from the page. On macOS, structuredContent.source \
identifies javascript, cdp, or ax_fallback; a successful AX fallback preserves \
the triggering error and truncation state in structuredContent.\n\
- query_dom: Find elements matching a CSS selector, with the same macOS fallback \
metadata contract as get_text.\n\
- click_element: Click a CSS-selected element AND animate the agent cursor \
to its on-screen center first (so the user sees what the agent is doing). \
Prefer over `execute_javascript('el.click()')` whenever you want visible \
Expand Down Expand Up @@ -458,8 +531,8 @@ impl Tool for PageTool {
}
}

"get_text" => match self.backend.get_text(pid, window_id).await {
Ok(text) => ToolResult::text(text),
"get_text" => match self.backend.get_text_result(pid, window_id).await {
Ok(result) => page_read_tool_result(result),
Err(e) => ToolResult::error(format!("Page text extraction failed: {e}")),
},

Expand Down Expand Up @@ -547,10 +620,10 @@ impl Tool for PageTool {
.unwrap_or_default();
match self
.backend
.query_dom(pid, window_id, &selector, &attributes)
.query_dom_result(pid, window_id, &selector, &attributes)
.await
{
Ok(text) => ToolResult::text(text),
Ok(result) => page_read_tool_result(result),
Err(e) => ToolResult::error(format!("DOM query failed: {e}")),
}
}
Expand All @@ -560,6 +633,16 @@ impl Tool for PageTool {
}
}

fn page_read_tool_result(result: PageReadResult) -> ToolResult {
let tool_result = ToolResult::text(result.content);
match result.metadata {
Some(metadata) => tool_result.with_structured(
serde_json::to_value(metadata).expect("page read metadata must serialize"),
),
None => tool_result,
}
}

fn optional_cdp_port(args: &Value) -> Result<Option<u16>, String> {
let Some(value) = args.get("cdp_port") else {
return Ok(None);
Expand Down Expand Up @@ -630,6 +713,74 @@ mod tests {
}
}

struct ProvenanceBackend;

#[async_trait]
impl PageBackend for ProvenanceBackend {
async fn get_text(&self, _pid: i32, _window_id: u64) -> anyhow::Result<String> {
Ok("legacy payload".to_owned())
}

async fn get_text_result(
&self,
_pid: i32,
_window_id: u64,
) -> anyhow::Result<PageReadResult> {
Ok(PageReadResult::with_metadata(
"legacy payload".to_owned(),
PageReadMetadata {
source: PageReadSource::AxFallback,
fallback_error: Some("JavaScript from Apple Events is disabled".to_owned()),
truncated: Some(true),
},
))
}

async fn query_dom(
&self,
_pid: i32,
_window_id: u64,
_css_selector: &str,
_attributes: &[String],
) -> anyhow::Result<String> {
Ok(String::new())
}

async fn execute_javascript(
&self,
_pid: i32,
_window_id: u64,
_javascript: &str,
) -> anyhow::Result<String> {
anyhow::bail!("unused")
}
}

#[tokio::test]
async fn read_preserves_text_and_attaches_backend_provenance() {
let tool = PageTool::new(Arc::new(ProvenanceBackend));
let result = tool
.invoke(serde_json::json!({
"pid": 42,
"window_id": 7,
"action": "get_text"
}))
.await;

assert!(matches!(
&result.content[0],
crate::protocol::Content::Text { text, .. } if text == "legacy payload"
));
assert_eq!(
result.structured_content,
Some(serde_json::json!({
"source": "ax_fallback",
"fallback_error": "JavaScript from Apple Events is disabled",
"truncated": true
}))
);
}

#[tokio::test]
async fn execute_javascript_forwards_explicit_page_target() {
let backend = Arc::new(RecordingBackend::default());
Expand Down
29 changes: 21 additions & 8 deletions libs/cua-driver/rust/crates/platform-macos/src/ax/tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,16 @@ pub struct AXNode {
pub struct TreeWalkResult {
pub tree_markdown: String,
pub nodes: Vec<AXNode>,
/// True when the walk was cut short by the MAX_ELEMENTS cap.
/// True when the walk was cut short by either the element or depth cap.
pub truncated: bool,
}

#[derive(Default)]
struct WalkTruncation {
element_limit: bool,
depth_limit: bool,
}

/// Walk the AX tree of `pid`, optionally filtered to a specific window.
///
/// `window_id` — when Some, only the AXWindow matching that CGWindowID is
Expand Down Expand Up @@ -142,9 +148,9 @@ pub fn walk_tree_bounded(
let mut index_counter = 0usize;
// Shared visited-node counter passed into walk_element to enforce the cap.
let mut visited_count = 0usize;
// Set to true only when walk_element actually stops early due to the cap —
// avoids a false-positive when the tree naturally ends on exactly the cap.
let mut truncated = false;
// Record only bounds that actually omit a node, avoiding a false-positive
// when the tree naturally ends exactly on either bound.
let mut truncated = WalkTruncation::default();

unsafe {
let app_elem = AXUIElementCreateApplication(pid);
Expand Down Expand Up @@ -243,21 +249,27 @@ pub fn walk_tree_bounded(
CFRelease(app_elem as CFTypeRef);
}

let truncated_flag = truncated;
let truncated_flag = truncated.element_limit || truncated.depth_limit;
let raw_markdown = render_lines(&lines);
let mut tree_markdown = if let Some(q) = query {
filter_tree(&raw_markdown, q)
} else {
raw_markdown
};

if truncated_flag {
if truncated.element_limit {
tree_markdown.push_str(&format!(
"\n⚠️ AX tree truncated at {max_elements} nodes \
(app has a very large accessibility tree — Arc, Electron, or similar). \
Element indices above are still valid. Use pixel clicks for elements \
not visible in this partial tree."
));
} else if truncated.depth_limit {
tree_markdown.push_str(&format!(
"\n⚠️ AX tree truncated beyond depth {max_depth}. \
Element indices above are still valid. Use pixel clicks for elements \
not visible in this partial tree."
));
}

TreeWalkResult {
Expand All @@ -276,17 +288,18 @@ unsafe fn walk_element(
lines: &mut Vec<(usize, String)>,
counter: &mut usize,
visited_count: &mut usize,
truncated: &mut bool,
truncated: &mut WalkTruncation,
max_elements: usize,
max_depth: usize,
) {
if depth > max_depth {
truncated.depth_limit = true;
return;
}
// Enforce total-node cap — mirrors Swift's maxElements guard.
// Set the truncated flag only when we actually stop early.
if *visited_count >= max_elements {
*truncated = true;
truncated.element_limit = true;
return;
}
*visited_count += 1;
Expand Down
Loading
Loading