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}