Skip to main content

cosh_tools/computer/
act.rs

1//! `computer_act` — semantic actions AND keyboard as one pipeline on the
2//! accessibility tree (no screen coordinates involved).
3//!
4//! One step is EITHER a semantic action on the element matched by
5//! `selector` (auto-waiting for visible + enabled), OR a keyboard action
6//! (`key`/`text` via the SHARED keyboard engine), OR a `wait` pause — and
7//! steps chain via `then` with shell `&&` semantics, each only if the
8//! previous succeeded, the first failure aborting the chain with the exact
9//! failing step and everything that completed.
10//!
11//! This file is ONLY the pipeline: chain walking, wait steps and step
12//! classification. The step KINDS live in their components — semantic
13//! dispatch in [`super::touch`], keyboard dispatch in [`super::keyboard`]
14//! — so each can be reused by other tools without carrying this pipeline
15//! along.
16//!
17//! The schema skeleton (`then` → `then` → `wait`) is IDENTICAL to
18//! `computer_control`'s on purpose: the model learns the pipeline shape
19//! once and carries it across both tools — only each tool's particular
20//! step kind differs (semantic actions here, pointer actions there).
21//!
22//! FOCUS CAVEAT: a semantic `press` focuses the target on many toolkits
23//! but NOT reliably on every one — the one mechanism that always moves OS
24//! keyboard focus is a real click, which is `computer_control`'s job. If
25//! chained typing keeps missing the field, click the field element there
26//! instead.
27//!
28//! Cancellation caveat: the blocking actions run on tokio's blocking pool
29//! and CANNOT be interrupted by dropping the future. If the caller aborts
30//! an `act` that is still auto-waiting, the underlying `spawn_blocking`
31//! task keeps running and the action may still fire once the element
32//! appears. Callers that need hard cancellation must gate the tool call
33//! itself (the harness-level authorization), not rely on future drops.
34use super::keyboard::{self, MAX_WAIT_MS};
35use super::touch;
36use super::types::{ActOutput, ComputerAct};
37
38/// Maximum number of steps in a `then` pipeline (the root step included) —
39/// the same cap `computer_control` uses, so the two pipelines behave alike.
40pub const ACT_CHAIN_MAX_DEPTH: usize = 8;
41
42/// Run a semantic pipeline on an accessibility-tree element: actions,
43/// keyboard steps and waits chained via `then`, executed sequentially in
44/// ONE call with `&&` semantics.
45///
46/// # Errors
47///
48/// Returns `Err` (before any side effect) for an invalid step anywhere in
49/// the chain: a semantic step without a target, keyboard/wait fields on a
50/// semantic step (or vice versa), `key` with `text`, `text` with `held`,
51/// an unknown key/modifier name, a missing `value`/`numeric_value`/`range`
52/// payload, a wait that is 0 or over the cap, or a chain deeper than
53/// [`ACT_CHAIN_MAX_DEPTH`]. Returns `Err` mid-chain (earlier steps stay
54/// applied — they were real actions) when the app or a visible+enabled
55/// selector match does not appear within the timeout, or the platform
56/// rejects the action.
57pub async fn act(input: &ComputerAct) -> Result<ActOutput, String> {
58    validate_chain(input, ACT_CHAIN_MAX_DEPTH)?;
59    // `spawn_blocking` needs 'static — clone the (small) input struct in.
60    let owned = input.clone();
61    tokio::task::spawn_blocking(move || act_blocking(&owned))
62        .await
63        .map_err(|e| format!("computer_act: blocking task failed: {e}"))?
64}
65
66/// Validate the WHOLE chain up front (shape + per-step rules) before any
67/// synthetic action: a malformed step 3 must not leave steps 1-2 applied
68/// with an error that reads like an execution failure.
69pub(crate) fn validate_chain(step: &ComputerAct, remaining: usize) -> Result<(), String> {
70    if remaining == 0 {
71        return Err(format!(
72            "computer_act: `then` chain exceeds {ACT_CHAIN_MAX_DEPTH} steps"
73        ));
74    }
75    validate_step(step)?;
76    if let Some(next) = &step.then {
77        validate_chain(next, remaining - 1)?;
78    }
79    Ok(())
80}
81
82/// Validate ONE step. A step is a WAIT step when `wait` is present (it is
83/// standalone), a KEYBOARD step when `key`/`text` is present, and otherwise
84/// a SEMANTIC step — the field groups are disjoint, and mixing them in one
85/// step is a mistake the model should hear about. The kind-specific rules
86/// live in the components.
87fn validate_step(step: &ComputerAct) -> Result<(), String> {
88    const TOOL: &str = "computer_act";
89
90    // Wait step: STANDALONE — it carries no action, so any other field next
91    // to it is ambiguous about what the wait applies to.
92    if let Some(ms) = step.wait {
93        let extra: Vec<&str> = [
94            (step.name.is_some(), "`name`"),
95            (step.pid.is_some(), "`pid`"),
96            (step.surface.is_some(), "`surface`"),
97            (step.selector.is_some(), "`selector`"),
98            (step.nth.is_some(), "`nth`"),
99            (step.action.is_some(), "`action`"),
100            (step.value.is_some(), "`value`"),
101            (step.numeric_value.is_some(), "`numeric_value`"),
102            (step.range.is_some(), "`range`"),
103            (step.timeout_ms.is_some(), "`timeout_ms`"),
104            (step.key.is_some(), "`key`"),
105            (step.text.is_some(), "`text`"),
106            (step.held.is_some(), "`held`"),
107        ]
108        .iter()
109        .filter(|(present, _)| *present)
110        .map(|(_, name)| *name)
111        .collect();
112        if !extra.is_empty() {
113            return Err(format!(
114                "{TOOL}: `wait` is a standalone step — it takes no {}",
115                extra.join(", ")
116            ));
117        }
118        if ms == 0 {
119            return Err(format!("{TOOL}: `wait` must be >= 1 ms"));
120        }
121        if ms > MAX_WAIT_MS {
122            return Err(format!(
123                "{TOOL}: `wait` is capped at {MAX_WAIT_MS} ms per step — \
124                 chain several wait steps for longer pauses"
125            ));
126        }
127        return Ok(());
128    }
129
130    let key = step.key.as_deref().map(str::trim).filter(|s| !s.is_empty());
131    let text = step.text.as_deref().filter(|t| !t.is_empty());
132
133    if key.is_some() && text.is_some() {
134        return Err(format!("{TOOL}: provide `key` or `text`, not both"));
135    }
136    if key.is_some() || text.is_some() {
137        // Keyboard step: typing goes into whatever holds keyboard focus —
138        // semantic targeting here has no meaning and would silently
139        // disagree with it.
140        for (present, name) in [
141            (step.name.is_some(), "`name`"),
142            (step.pid.is_some(), "`pid`"),
143            (step.surface.is_some(), "`surface`"),
144            (step.selector.is_some(), "`selector`"),
145            (step.nth.is_some(), "`nth`"),
146            (step.action.is_some(), "`action`"),
147            (step.value.is_some(), "`value`"),
148            (step.numeric_value.is_some(), "`numeric_value`"),
149            (step.range.is_some(), "`range`"),
150            (step.timeout_ms.is_some(), "`timeout_ms`"),
151        ] {
152            if present {
153                return Err(format!(
154                    "{TOOL}: {name} belongs to a semantic-action step — a keyboard step \
155                     types into the focused element; act on the target element in an \
156                     earlier step, then chain the typing via `then`. On a SHELL SURFACE \
157                     the pattern is: a semantic `press` with `surface` first (that is \
158                     what moves keyboard focus to the flyout), then this keyboard step \
159                     with no `surface`. If the typed text keeps missing the field, click \
160                     the field element with computer_control instead — a real click is \
161                     the one mechanism that reliably moves keyboard focus"
162                ));
163            }
164        }
165        if text.is_some() {
166            if step.held.is_some() {
167                return Err(format!(
168                    "{TOOL}: `held` is rejected with `text` ({text:?}) — backends \
169                     synthesize case/shift; use `key` with `held` for chords"
170                ));
171            }
172        } else {
173            keyboard::parse_key(key.unwrap_or_default(), TOOL)?;
174        }
175        keyboard::parse_keys(step.held.as_deref().unwrap_or_default(), TOOL)?;
176        return Ok(());
177    }
178
179    // Semantic step: the kind-specific rules live in the touch component.
180    touch::validate(step, TOOL)
181}
182
183/// True when walking the chain will need the input simulator — i.e. some
184/// step is a keyboard step (semantic actions go through the accessibility
185/// tree, waits through `std::thread`, neither touches the simulator).
186/// Acquiring the simulator lazily keeps a purely semantic chain working on
187/// systems where no input backend exists.
188///
189/// The recursion must pass THROUGH wait steps, not stop at them: a wait
190/// may carry a `then` continuation (the canonical settle-then-type chain),
191/// so only the keyboard classification of each step decides — never an
192/// early `false` on the wait itself.
193pub(crate) fn chain_needs_sim(step: &ComputerAct) -> bool {
194    keyboard::is_keyboard_step(step.key.as_deref(), step.text.as_deref())
195        || step.then.as_deref().is_some_and(chain_needs_sim)
196}
197
198fn act_blocking(input: &ComputerAct) -> Result<ActOutput, String> {
199    let sim = if chain_needs_sim(input) {
200        Some(keyboard::shared_sim().map_err(|e| format!("computer_act: input backend: {e}"))?)
201    } else {
202        None
203    };
204    // Walk the chain iteratively (&&-semantics): one step at a time, each
205    // only reached if the previous succeeded; the first failure aborts with
206    // the exact step and everything that completed.
207    let mut report = String::new();
208    let mut step = input;
209    let mut index = 1usize;
210    loop {
211        let sent = run_step(sim.as_ref(), step).map_err(|e| {
212            if report.is_empty() {
213                format!("computer_act: step {index}: {e}")
214            } else {
215                format!("computer_act: step {index}: {e} — completed: {report}")
216            }
217        })?;
218        if !report.is_empty() {
219            report.push_str(" → ");
220        }
221        report.push_str(&sent);
222        match &step.then {
223            Some(next) => {
224                step = next;
225                index += 1;
226            }
227            None => return Ok(ActOutput { sent: report }),
228        }
229    }
230}
231
232/// Execute ONE validated step and return its human-facing report.
233fn run_step(sim: Option<&xa11y::InputSim>, step: &ComputerAct) -> Result<String, String> {
234    // Wait step: a plain pause (validated standalone).
235    if let Some(ms) = step.wait {
236        return Ok(keyboard::run_wait_step(ms));
237    }
238    // Classify EXACTLY like validate_step via the SHARED engine helpers.
239    let key = step.key.as_deref();
240    let text = step.text.as_deref();
241    if keyboard::is_keyboard_step(key, text) {
242        let sim = sim.expect("chain_needs_sim: a keyboard step guarantees a simulator");
243        keyboard::run_keyboard_step(
244            sim,
245            &keyboard::KeyboardStep {
246                key,
247                text,
248                held: step.held.as_deref(),
249            },
250            "computer_act",
251        )
252    } else {
253        touch::run_step(step, "computer_act")
254    }
255}