Skip to content

feat(tool-server): add find tool to locate an element and optionally act on it#427

Open
hubgan wants to merge 16 commits into
mainfrom
feat/find-tool
Open

feat(tool-server): add find tool to locate an element and optionally act on it#427
hubgan wants to merge 16 commits into
mainfrom
feat/find-tool

Conversation

@hubgan

@hubgan hubgan commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds a find tool that locates a UI element by a locator and optionally performs an action on the match, collapsing the usual describe -> read coordinates -> gesture-tap cycle into a single call. Works on iOS, Android, and Chromium (not Vega, which has no touch model).

find { query: "Sign In", by: "text", action: "tap" }
  • Locators (by): any (label/value/id, the default), text (label or value), label, value, role, id. Case-insensitive substring.
  • Actions (action): tap (default, centre), focus, type (focus + type), fill (focus + clear + type), exists (single presence check), wait (block until visible), get-text, get-attrs.
  • Returns { found, matchCount, match?, actionResult?, elapsed, note? }. matchCount flags ambiguous queries; index disambiguates. A single tree snapshot is taken by default; timeoutMs opts into polling until the element appears before acting.

How it's built

  • Delegates the device effect to the existing gesture-tap / keyboard tools via invokeSubTool (free telemetry attribution; no duplicated transport; read-only actions never spawn the simulator-server).
  • Extracts the shared, hardened tree-matching primitives into describe/match.ts (root-excluding walk, reading-order sort, visibility proxy) and the tree fetch + settleWithin into describe/fetch-tree.ts / utils/timing.ts, reused by both find and await-ui-element (the latter shrinks by ~170 lines, behaviour-preserving).
  • Per-platform focus settle so the first keystroke isn't dropped while a soft keyboard raises; a centre-tap clear for fill (verified to fully clear fields on iOS and Android); fetch bounded by the deadline + abort signal so wait can't overshoot timeoutMs.
  • find is intentionally kept out of run-sequence for v1: unlike await-ui-element, a missed find returns { found: false } without throwing, which the sequence stop-guard wouldn't catch (documented inline). The skill/flow docs are updated to match.

Verification

  • vitest: new find.test.ts (38 cases) plus await-ui-element / run-sequence / sub-invoke / flows all green; tsc --noEmit and eslint clean.
  • End-to-end on iPhone 16 Pro and Pixel 3a against a real app: exists, get-attrs, tap (verified tab switch via before/after screenshots), type, fill (clean clear + replace), and wait (hit + timeout). The device run caught and fixed an Android first-keystroke-drop race.

Draft for review.

@hubgan hubgan requested a review from latekvo June 30, 2026 11:45
@hubgan hubgan marked this pull request as ready for review June 30, 2026 11:45

@latekvo latekvo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed end-to-end: built and ran the tool-server suite, drove the compiled find against a real device's accessibility tree (it selected the correct element, flagged the ambiguity, and the computed tap landed on the intended row), and exercised the edge paths directly. The shared-primitive extraction and the await-ui-element refactor are behaviour-preserving, and the locator/selection logic is sound. Two inline notes.

registry,
ctx,
params.udid,
chosen.value?.length ?? 0

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fill derives the number of backspaces to clear the field from chosen.value?.length, but on several platforms a populated field's current editable text is not in node.value. On Chromium an <input>'s text is exposed as node.label (accessibleName → el.value/placeholder), while node.value is ownText(), which is empty for an input; on Android an EditText without a content-desc likewise reports its text as label with value unset (makeUiNode only sets value when text !== label). In those cases chosen.value?.length is 0, so clearField sends only the 2-char CLEAR_BUFFER no matter how much text the field holds, and fill types the new text on top of the leftover — e.g. filling a field containing old@example.com with NEW yields old@example.cNEW. The result still reports found: true, clearedChars: 2, and no note (the only incomplete-clear caveat is the masked-password branch below). Confirmed against the compiled tool with a Chromium-shaped input node (15 chars of text in label): 2 backspaces sent, 13 characters left behind, success reported. (Separately, even when value is correctly populated, the clear is capped at MAX_CLEAR_CHARS = 64 with the same silent-success behavior for longer fields.)

@@ -0,0 +1,21 @@
const FIND_TOOL_ID = "find";

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FIND_TOOL_ID is re-declared here as a local "find" literal, while the tool already exports FIND_TOOL_ID from tools/find/index.ts — the parallel await-ui-element guard imports its shared AWAIT_UI_ELEMENT_TOOL_ID constant for exactly this purpose. If the tool id ever changes, this private copy silently stops matching and missed-find detection in both flow-add-step and flow-run regresses with no compile error.

@hubgan hubgan requested a review from latekvo July 1, 2026 10:55

@latekvo latekvo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the full diff and ran it end-to-end against a live iOS 18.5 simulator and a live Chromium (CDP) target, plus the tool-server suite (1665 tests) and argent-mcp suite — all green — and tsc / eslint / prettier clean.

The tool behaves as intended on device: exists, get-attrs, the not-found diagnostics, the ambiguity matchCount/note, tap (verified a real navigation), type, wait (hit and bounded timeout), and fill (clean replacement of a long overflowing single-line field) all worked. The await-ui-element refactor is behaviour-preserving, the flow record/replay integration and the run-sequence exclusion are sound, and the skill/docs changes match the implementation. No regressions in the affected packages.

Two things worth a look, both inline below and neither blocking the core behaviour: a Chromium contenteditable case where fill can silently under-clear, and a cancellation note that misdescribes a field that was already touched.

// unknowable — clear up to the cap so a populated field is emptied
// regardless, and flag it. Elsewhere max(value,label) is the true
// length and never shorter than the real text.
const lengthHidden = device.platform === "chromium" && !chosen.value;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On Chromium, fill sizes its backspace-clear from the described value (via editableTextLength), and the cap-and-warn path is only taken when value is empty (lengthHidden). But for a contenteditable element the DOM accessibility snapshot sets value to the element's own direct text nodes only — it excludes any text inside nested inline children (a <b>, a mention/emoji span, a link). A populated contenteditable therefore reports a non-empty but undercounted value, so lengthHidden stays false and the safeguard just below is skipped: the clear is sized to the undercount, only the leading characters are deleted, the remainder stays in the field, and the new text is inserted in front of it — reported as a successful fill with no caveat.

Reproduced on Chrome: find fill on <div contenteditable>abc<b>defghijklmnopqrst</b></div> (real content abcdefghijklmnopqrst) with text ZZZ sent 5 backspaces (clearedChars: 5), returned note unset, and the element's textContent ended up as abcdefghijklmnoZZZ rather than ZZZ.

...baseResult(),
found: false,
matchCount: 0,
note: "find was cancelled before the element was located",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This message is also returned from the type and fill abort paths — when the signal fires during the focus settle (the focusAndSettle false-return) or mid-clear just below. On those paths the element has already been located and a device effect has already been dispatched: the focus tap always fires, and for fill one or more backspaces may have already deleted field content. So on a mid-action cancel the field can end up focused and partially cleared, yet the result reports found: false, matchCount: 0 with "cancelled before the element was located" — telling a caller nothing happened when the field was in fact mutated, which is misleading for recovery.

@latekvo latekvo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed the full diff independently, drove the compiled find tool end-to-end against a live iOS 18.5 device, and re-ran the tool-server + argent-mcp suites (all green). The shared-primitive extraction (describe/match.ts, describe/fetch-tree.ts, utils/timing.ts) and the await-ui-element refactor are behaviour-preserving; the locator/selection, poll/deadline, and abort logic are sound; the flow record/replay integration and the run-sequence exclusion are correct; and the skill/docs changes match the implementation.

On device I confirmed exists, get-attrs, the not-found diagnostics, the matchCount ambiguity note, tap (verified a real navigation), type, and fill all behave as intended. I specifically stress-tested fill on a wide field holding content that extends past the tap centre on native iOS fields (both a search field and a plain text field, each not pre-focused): it cleared the field completely in every case — the focusing tap parks the caret at the end of the existing text, so the backspace clear is not corrupted by tap position.

The two earlier inline notes that were substantive have been resolved (the fill clear now sizes from the longer of value/label via editableTextLength, and flow-step-results.ts now imports FIND_TOOL_ID rather than re-declaring it). The Chromium-fill under-clear and the cancellation-note wording remain as previously noted and are non-blocking. One small documentation note inline.

Approving.

Fails if the flow file does not exist or a step tool raises an error (execution stops at that step).
An \`await-ui-element\` step whose condition is not met before its timeout also stops the flow there
(its later steps were recorded assuming the condition held), so use one to gate a step on a screen transition.
A \`find\` step that returns \`{ found: false }\` also stops the flow there, so replay never silently

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This says a find step returning { found: false } stops the flow, but isMissedFindResult exempts action: "exists" — an exists step reporting found: false is a successful "not present" answer and neither stops the flow here nor blocks recording in flow-add-step. As written, the description (and the matching one in flow-add-step) reads as if every found: false halts replay, which would mislead an agent that uses find … action:"exists" as a presence check inside a flow.

hubgan and others added 9 commits July 2, 2026 09:53
…act on it

Combines discovery + action in one round-trip: locate a UI element by a
locator (any/text/label/value/role/id) and optionally tap, focus, type,
fill, check existence, wait for it, or read its text/attrs.

Extracts the shared tree-matching primitives (root-excluding walk,
reading-order sort, visibility proxy) into describe/match.ts, reused by
both find and await-ui-element.
- Share the per-platform tree fetch (describe/fetch-tree.ts) and settleWithin
  (utils/timing.ts) with await-ui-element; bound find's poll/fetch by the
  deadline and abort signal so wait can't overshoot timeoutMs on a hung describe.
- fill/type focus with a centre tap (empirically clears the field on iOS+Android)
  instead of a trailing-edge tap that could hit a field's clear button.
- Platform-aware focus settle (Android 450ms / iOS 150ms / Chromium 0) so the
  first keystroke isn't dropped while the soft keyboard raises.
- exists now folds degraded-AX hints into its not-found note; clamp the poll
  sleep; matchCount counts the actionable pool; action-aware ambiguity note;
  append (not overwrite) the password warning; honor abort after the focus tap;
  drop the redundant acted field and the duplicate get-attrs payload.
- Add find to the device-interact skill + tapping rule; note why find is kept
  out of run-sequence; expand tests (degraded note, fetch error, action-phase
  abort, centre-tap, clear bounds, ambiguity note, focus, get-text label-only).
…lear

clearField stops early on abort but can't signal it; without this guard a fill
cancelled during the backspace loop would still push `text` in and report a
successful fill instead of returning cancelled(). Adds a deterministic test.
- Reference find in the device-interact / RN-workflow / screenshot-diff /
  test-ui-flow / setup skills and the argent rule as a locate-and-act tool.
- Clarify flow recording: flow-add-step records a step unless the tool throws,
  so a non-throwing failure (find's { found: false }) is still recorded; do not
  record find steps in v1 flows, and inspect each result before continuing.
- Note find's actionResult.timestampMs (tap/focus) for profiler annotations.
Addresses the outstanding review comments after the rebase onto main:

- flow-run / flow-add-step docs now state that a `find` with
  action:"exists" is exempt from the found:false stop/skip rule (its
  found:false is a valid "not present" answer), matching isMissedFindResult.
- `fill` on Chromium always sizes the clear from the cap: a contenteditable
  reports a non-empty but undercounted `value` (direct text nodes only), so
  the old `!value` guard under-cleared and left stale text. Caveat wording
  updated to cover both inputs and contenteditable.
- A cancel that lands after the element was located and a device effect was
  dispatched (type/fill focus settle, or mid-clear) now reports found:true
  with an accurate note instead of "cancelled before the element was located".

Integration with main: main's describeIos now probes isTvOsSimulator
(shelling xcrun) per fetch. find now pre-resolves the tvOS verdict once
before the wait clock and threads it through fetchDescribeTree — mirroring
await-ui-element — so polling never re-shells xcrun per iteration. find
tests stub isTvOsSimulator for determinism.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address code-review findings on the find tool:

- H1: bias fill's focus tap to the field trailing edge so the leftward
  clear starts at end-of-text (not a mid-text caret), and rename the
  result field clearedChars -> backspacesSent (it counts backspaces sent,
  not chars proven removed).
- M1: for tapping actions, demote a match whose frame encloses another so
  default index:0 lands on the inner input, not an aggregating Android
  container that folds the child's text into its content-desc.
- M2: reject acting actions (tap/focus/type/fill) on a TV target up front
  (tvOS + Android TV) before any device effect, so fill can't throw on the
  first backspace or silently no-op; find is read-only on TV.
- L1: exists reports the topmost visible match (not a zero-area ghost).
- L2: exists sets presenceUnknown when the tree was unreadable, so a blind
  read isn't reported as a confirmed absence.
- N1: poll-timeout note says "poll budget" for polling non-wait actions.
- Docs (D1-D4): SKILL index/TV semantics, run-sequence exclusion rationale,
  sub-invoke caller list.
- Tests (T1-T5): slow/hung/aborted fetch, poll-sleep clamp, tvOS probe
  once, type mid-settle abort, matchCount all-vs-visible, plus M1/M2.
- T6: mock isTvOsSimulator/isAndroidTv in await-ui-element tests so the
  describe path stops shelling out per poll (the real flakiness root).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
hubgan added 7 commits July 2, 2026 13:18
…ction; flag fill over-delete

- fill: extend the Chromium clear caveat to also warn about the over-delete
  direction (an inner block of a multi-block contenteditable can have its
  fixed-count backspaces run into the preceding block), so both under- and
  over-clear are surfaced instead of only leftover text.
- flow-execute: assert a recorded `exists` find returning found:false does
  not stop the flow (replay side of the found:false exemption).
- find: assert a read-only action on a tvOS target is not rejected and
  returns a valid absent answer carrying the tvOS hint (tvOS AX serves no
  focus tree, so it is found:false, unlike Android TV's real uiautomator tree).
- describeChromium: run the real DOM walker against a faithful fake DOM to
  verify an <input> yields label=placeholder with empty value, and a
  contenteditable exposes only its direct text nodes.
…edges

- fill now clears the enclosing editable region, not the demoted inner
  proxy, and parks the caret at the field's bottom-trailing corner — fixes
  the iOS search-bar "abcd" -> "WxyzAbcd" corruption and multi-line residue
- editableTextLength prefers `value` when present so an empty field's
  placeholder label isn't backspaced (and can't trip a false cap warning)
- exists reports presenceUnknown for any blind read (empty tree flagged with
  a hint/should_restart: tvOS, degraded AX, mid-restart), mirroring
  await-ui-element's isBlindRead — not just lastTree === null
- TV acting-guard fails closed on an indeterminate Android form-factor probe;
  iOS left as-is (a physical iPhone legitimately probes undefined)
- not-found note no longer mislabels a genuine miss as a fetch failure after
  a transient error poll
- flow-add-step rejects an unmet await-ui-element (would halt every replay)
- read-only find actions use describe's short auto-screenshot delay
- clarify action-dependent `index` semantics in the zod help and runtime note;
  document fill's destructive clear in the tool description and SKILL

Tests: enclosing-region fill, empty-field clear, blind-read exists,
fail-closed probe, exists topmost-visible, run-sequence exclusion, unmet-wait
recording, and auto-screenshot delay. tool-server 1865 pass, argent-mcp 68 pass.
The fake-DOM harness runs describeChromium's own page-eval expression via
new Function against injected globals — an intentional, non-attacker-controlled
use. eslint-disable-next-line keeps the branch's ESLint check green.
… (fix CI)

find.test.ts: also mock isAndroidTv, not just getAndroidRuntimeKind. The Android
describe path (`describeAndroid`) calls the real isAndroidTv for a TV hint, which
shells `adb`. Mocking only getAndroidRuntimeKind left isAndroidTv real, so the
Android find tests passed only where adb is on PATH (a dev laptop / the CI
Android image) and shelled adb otherwise. Mocking both keeps every Android path
adb-free on any host.

chromium-describe-value-extraction.test.ts: evaluate describeChromium's page-eval
script in a `vm` sandbox instead of a bare `new Function`. A browser runs it
against a complete `window` where every global resolves (to a value or
undefined); `new Function` resolves free identifiers against Node's sparse global
scope, so a stray DOM-global reference threw "Node is not defined" on the CI
runner while passing locally. The sandbox's permissive `has` trap makes any
un-provided global resolve to undefined (built-ins fall through to globalThis),
matching browser semantics and removing the environment-dependent ReferenceError.
Also drops the no-implied-eval disable (vm.runInContext isn't implied eval).

Verified: full tool-server suite 1865 pass on macOS (node 24) and in a Linux
node-20 container (matching CI, adb installed), isolated and full-suite.
The vm/Proxy sandbox from the previous commit crashed vm.createContext on the CI
runner ("Cannot read properties of undefined (reading 'prototype')") — a permissive
`has` trap doesn't survive Node's vm internals there. Revert to the original
`new Function` harness and instead inject `Node` (and `HTMLElement`) as explicit
params. This repo's page script never references `Node`, yet the CI runner threw
"Node is not defined" evaluating it (a stray DOM-global reference resolved against
Node.js's sparse global scope); binding `Node` to an injected stub makes that
identifier resolve regardless of host, which is a guaranteed fix for that exact
ReferenceError with none of the vm fragility.
thatswiftdev added a commit to thatswiftdev/argent that referenced this pull request Jul 8, 2026
… elements by text/label/role/id and optionally act on them

Resolves conflicts with main's await-screen-idle refactor by keeping both:
- HEAD's await-screen-idle + pollDescribeTree improvements
- PR software-mansion#427's find tool + describe/fetch-tree extraction

The find tool collapses describe → read-coords → gesture-tap into a single
call: find { query: "Sign In", by: "text", action: "tap" }

Original PR: software-mansion#427
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants