fix(android): expose stable native selectors with exact pixel bounds

This commit is contained in:
chenmingxuan committed 2026-11-03 00:02:35 +08:00
1 parent 4ea7939d9c
commit dc3a48f6b2
13 files changed
+313 -43

No files matched your search

+3 -1
View File
@@ -7,10 +7,12 @@
# ROLE & OBJECTIVE # ROLE & OBJECTIVE
You are an autonomous UI execution agent. Accomplish the user's objective using the actual available device and plugin tools. Android actions use normalized 0–1000 coordinates unless drag/action_sequence explicitly selects pixel; plugin tools use their own documented coordinates and element indices. You are an autonomous UI execution agent. Accomplish the user's objective using the actual available device and plugin tools. Android actions use normalized 0–1000 coordinates unless drag/action_sequence explicitly selects pixel; native selector.bounds always uses original Android screen pixels. Plugin tools use their own documented coordinates and element indices.
**Objective: {{ goal }}** **Objective: {{ goal }}**
{% if "read_control" in available_tools %}- **原生选择器**:直接复制当前节点的 `node.native_selector` 对象,用于读取、等待、状态确保和数值反馈。列表 `Bounds` 是 0–1000 归一化坐标,不能填入需要 Android 原始像素的 `selector.bounds`,也不能反算取整后的 Bounds。不要把正在变化的时间文本或描述加进身份筛选;要等待的文本放 expected_text。NOT_FOUND 时重新观察并核对选择器,不能据此认定 APP 不提供原生读数;重复 ID 的消歧边界以 native_selector 内的原始像素为准。{% endif %}
{% if "drag" in available_tools %}- **数值目标的闭环**:先观察真实手势区域,为 drag/pinch/touch_gesture 填 `drag.region`,不要把日期栏、导航栏当作时间轴。若原生可读取持久数值,为 drag 或 action_sequence 绑定 `action_sequence.feedback`(唯一稳定 selector、value_format、target_value、tolerance);时分秒用 clock,单位为当天秒数。组合“拖动→暂停”时只在整个序列上绑定 feedback,末尾读稳定值,不能在拖动中途的浮层上核验。已验证的同一持久读数在允许误差内就是此数值条件的证据;日期、暂停等其它要求仍分别核对,不能无依据地改称误差无法判断。连续无效反馈会拒绝第三次同策略输入;放大并重新读刻度、改为足够距离或原生设值,不能靠微改坐标绕过。calibration.tolerance 给出几何误差预算;min_movement_px 只填写实际测得的起效距离,不猜固定阈值。没有可读的持久数值时保留视觉证据与不确定项,不捏造 selector 或读数。{% endif %} {% if "drag" in available_tools %}- **数值目标的闭环**:先观察真实手势区域,为 drag/pinch/touch_gesture 填 `drag.region`,不要把日期栏、导航栏当作时间轴。若原生可读取持久数值,为 drag 或 action_sequence 绑定 `action_sequence.feedback`(唯一稳定 selector、value_format、target_value、tolerance);时分秒用 clock,单位为当天秒数。组合“拖动→暂停”时只在整个序列上绑定 feedback,末尾读稳定值,不能在拖动中途的浮层上核验。已验证的同一持久读数在允许误差内就是此数值条件的证据;日期、暂停等其它要求仍分别核对,不能无依据地改称误差无法判断。连续无效反馈会拒绝第三次同策略输入;放大并重新读刻度、改为足够距离或原生设值,不能靠微改坐标绕过。calibration.tolerance 给出几何误差预算;min_movement_px 只填写实际测得的起效距离,不猜固定阈值。没有可读的持久数值时保留视觉证据与不确定项,不捏造 selector 或读数。{% endif %}
{% if "control_action" in available_tools %}- **先状态后动作**:对于暂停、勾选等目标状态,先 read_control 确认其真实原生语义,再用 control_action.ensure_state;已符合时跳过,否则只操作一次并有界核验。可按观察到的 checked/selected、text 或 content_desc 判断;不能假设 selected 就表示暂停。selector 不包含动作会改变的文本/描述。拖动可能恢复播放,把确保暂停放在拖动后同一动作序列中;状态核验失败时重新观察,不重复盲点切换。{% endif %} {% if "control_action" in available_tools %}- **先状态后动作**:对于暂停、勾选等目标状态,先 read_control 确认其真实原生语义,再用 control_action.ensure_state;已符合时跳过,否则只操作一次并有界核验。可按观察到的 checked/selected、text 或 content_desc 判断;不能假设 selected 就表示暂停。selector 不包含动作会改变的文本/描述。拖动可能恢复播放,把确保暂停放在拖动后同一动作序列中;状态核验失败时重新观察,不重复盲点切换。{% endif %}
+5 -1
View File
@@ -878,9 +878,13 @@ _NATIVE_DESCRIPTIONS = {
), ),
"read_control": ( "read_control": (
"[OBSERVE] Read ONE live native control by exact AND selector copied from the current UI. " "[OBSERVE] Read ONE live native control by exact AND selector copied from the current UI. "
"Use resource_id/text/content_desc/class_name and optional package_name/bounds to disambiguate. " "Copy its native_selector object unchanged; it uses stable identity and original pixel bounds "
"only when needed to disambiguate. UI Bounds are normalized 0..1000 and MUST NOT be used "
"as selector.bounds (exact Android pixels); rounded UI bounds cannot be converted back exactly. "
"Do not add the changing text/content_desc you want to read or wait for as an identity filter. "
"Returns current range/value, supported native actions, editable selection (UTF-16 offsets), " "Returns current range/value, supported native actions, editable selection (UTF-16 offsets), "
"identity token and visible subtree content_signature. Missing or ambiguous controls fail explicitly. " "identity token and visible subtree content_signature. Missing or ambiguous controls fail explicitly. "
"On NOT_FOUND refresh the UI and selector; it does not prove that native reading is unavailable. "
"Custom canvas controls may expose no range/actions; then use calibrated gestures and visual verification." "Custom canvas controls may expose no range/actions; then use calibrated gestures and visual verification."
), ),
"control_action": ( "control_action": (
+11 -3
View File
@@ -26,11 +26,17 @@ class ControlSelector(GestureModel):
model_config = ConfigDict(str_strip_whitespace=False) model_config = ConfigDict(str_strip_whitespace=False)
resource_id: ShortText | None = None resource_id: ShortText | None = None
text: Annotated[str, StringConstraints(strip_whitespace=False, max_length=4096)] | None = None text: Annotated[str, StringConstraints(strip_whitespace=False, max_length=4096)] | None = Field(
default=None,
description="Exact identity filter, NOT the text to read or wait for. Omit changing readouts; use the observed native_selector instead.",
)
content_desc: ShortText | None = None content_desc: ShortText | None = None
class_name: ShortText | None = None class_name: ShortText | None = None
package_name: ShortText | None = None package_name: ShortText | None = None
bounds: Bounds | None = None bounds: Bounds | None = Field(
default=None,
description="Exact original Android screen PIXELS [left,top,right,bottom], only when needed to disambiguate. Never copy normalized 0..1000 UI Bounds or convert their rounded values back to pixels. Prefer the observed native_selector.",
)
@model_validator(mode="after") @model_validator(mode="after")
def meaningful_selector(self): def meaningful_selector(self):
@@ -44,7 +50,9 @@ class ControlSelector(GestureModel):
class ReadControlRequest(GestureModel): class ReadControlRequest(GestureModel):
selector: ControlSelector selector: ControlSelector = Field(
description="Copy the current observation's native_selector object unchanged. Every supplied attribute is an exact AND filter. Do not add changing text/content_desc or normalized UI Bounds.",
)
ControlAction = Literal[ ControlAction = Literal[
+6
View File
@@ -389,6 +389,12 @@ def _filter_and_prune(
bounds_data = _parse_bounds(node.get("bounds")) bounds_data = _parse_bounds(node.get("bounds"))
if bounds_data: if bounds_data:
# Hit-test bounds may be clipped or merged later; native identity must
# retain the exact rectangle originally reported by Android.
node.setdefault(
"native_bounds_px",
[bounds_data[key] for key in ("left", "top", "right", "bottom")],
)
# We no longer check _intersects_screen to return [] immediately, # We no longer check _intersects_screen to return [] immediately,
# to ensure zero info loss. Out-of-bounds elements will be clipped to a point. # to ensure zero info loss. Out-of-bounds elements will be clipped to a point.
+34
View File
@@ -12,6 +12,7 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
from collections import Counter
import io import io
import json import json
import os import os
@@ -351,6 +352,12 @@ def format_minimal_list_with_elements(
idx = 1 idx = 1
added_items = [] # list of tuples: (text, cx, cy) added_items = [] # list of tuples: (text, cx, cy)
items_bounds = [] # list of tuples: (idx, (left, top, right, bottom)) items_bounds = [] # list of tuples: (idx, (left, top, right, bottom))
resource_counts = Counter(
resource_id
for node in fused_xml
if isinstance(resource_id := node.get("resource-id") or node.get("resource_id"), str)
and resource_id
)
def normalize_bounds(left: int, top: int, right: int, bottom: int) -> str: def normalize_bounds(left: int, top: int, right: int, bottom: int) -> str:
n_left = int(max(0, min(1000, left * 1000 / width))) n_left = int(max(0, min(1000, left * 1000 / width)))
@@ -450,6 +457,31 @@ def format_minimal_list_with_elements(
line += f" | range: {json.dumps(native_state['range'], ensure_ascii=False)}" line += f" | range: {json.dumps(native_state['range'], ensure_ascii=False)}"
if native_state.get("actions"): if native_state.get("actions"):
line += f" | actions: {','.join(native_state['actions'])}" line += f" | actions: {','.join(native_state['actions'])}"
# Displayed Bounds are normalized and lossy. Native matching needs
# exact pixels; changing readouts are values, not identity filters.
native_selector: dict[str, Any] = {
key: value
for key, value in (
("resource_id", node.get("resource-id") or node.get("resource_id")),
("class_name", node.get("class") or node.get("class_name")),
("package_name", node.get("package") or node.get("package_name")),
)
if isinstance(value, str) and 0 < len(value) <= 512
}
if (
native_selector.get("resource_id") or native_selector.get("class_name")
) and "[OCR]" not in str(node.get("class", "")):
resource_id = native_selector.get("resource_id")
if not resource_id or resource_counts[resource_id] > 1:
native_selector["bounds"] = node.get("native_bounds_px") or [
left,
top,
right,
bottom,
]
line += f" | native_selector: {json.dumps(native_selector, ensure_ascii=False)}"
else:
native_selector = {}
if error: if error:
line += f" | Error: '{error}'" line += f" | Error: '{error}'"
if value_evidence: if value_evidence:
@@ -474,6 +506,8 @@ def format_minimal_list_with_elements(
element["error"] = error element["error"] = error
element.update(value_evidence) element.update(value_evidence)
element.update(native_state) element.update(native_state)
if native_selector:
element["native_selector"] = native_selector
if native_control: if native_control:
element["content_desc"] = node.get("content-desc") or "" element["content_desc"] = node.get("content-desc") or ""
element["native_text"] = node.get("text") or "" element["native_text"] = node.get("text") or ""
+2
View File
@@ -19,6 +19,8 @@ Flash、普通 Pro 和探索共用同一套动作参数、助手执行入口与
先 `read_control`,再按实际 `actions` 操作。范围设值使用 `action="set_progress", value=...`,严格检查实际 min/max;自绘时间轴没有 range 时不能直接设置秒数。`expected_token` 可绑定之前的读数;目标文本、位置或身份变化时返回 `STALE`,由模型重新观察,避免使用旧目标。 先 `read_control`,再按实际 `actions` 操作。范围设值使用 `action="set_progress", value=...`,严格检查实际 min/max;自绘时间轴没有 range 时不能直接设置秒数。`expected_token` 可绑定之前的读数;目标文本、位置或身份变化时返回 `STALE`,由模型重新观察,避免使用旧目标。
观察列表中的 `native_selector` 是可直接复制到读取、等待、状态确保及数值反馈中的原生选择器。它优先使用资源 ID、类名和包名,不把当前读数或描述当作身份;重复 ID 或没有 ID 时才附加原始像素边界。列表 `Bounds` 是用于手势的 0–1000 归一化坐标,而 `selector.bounds` 必须是 Android 原始屏幕像素,不能混用,也不能将有损取整的列表坐标反算回去。可点击区域经过裁剪或合并时仍保留原生节点的原始边界。选择器仍逐项精确 AND 匹配;布局改变、原生窗口中有其它同名节点时可能失效或歧义,需要重新观察。`NOT_FOUND` 只表示本次选择器未匹配,不能直接推断控件没有原生语义。等待的目标文本放 `expected_text`,不要塞入身份筛选。
文本动作包括 `set_text`、`set_selection`、`replace_selection`、`ime_enter`。选择区使用 Android UTF-16 索引,不能切开代理对;例如 `A😀B中文` 中 emoji 的范围是 `[1,3]`。`replace_selection` 保留其它文字并将光标移至插入末尾;空白与空字符串按原样传入。密码字段不公开原文,不支持基于隐藏原文替换选区。`ime_enter` 仅提交控件支持的 IME 动作,不代表业务表单已成功提交。 文本动作包括 `set_text`、`set_selection`、`replace_selection`、`ime_enter`。选择区使用 Android UTF-16 索引,不能切开代理对;例如 `A😀B中文` 中 emoji 的范围是 `[1,3]`。`replace_selection` 保留其它文字并将光标移至插入末尾;空白与空字符串按原样传入。密码字段不公开原文,不支持基于隐藏原文替换选区。`ime_enter` 仅提交控件支持的 IME 动作,不代表业务表单已成功提交。
## 手势、组合与验证 ## 手势、组合与验证
@@ -19,6 +19,8 @@ Flash、普通 Pro 和探索共用同一套动作参数、助手执行入口与
先 `read_control`,再按实际 `actions` 操作。范围设值使用 `action="set_progress", value=...`,严格检查实际 min/max;自绘时间轴没有 range 时不能直接设置秒数。`expected_token` 可绑定之前的读数;目标文本、位置或身份变化时返回 `STALE`,由模型重新观察,避免使用旧目标。 先 `read_control`,再按实际 `actions` 操作。范围设值使用 `action="set_progress", value=...`,严格检查实际 min/max;自绘时间轴没有 range 时不能直接设置秒数。`expected_token` 可绑定之前的读数;目标文本、位置或身份变化时返回 `STALE`,由模型重新观察,避免使用旧目标。
观察列表中的 `native_selector` 是可直接复制到读取、等待、状态确保及数值反馈中的原生选择器。它优先使用资源 ID、类名和包名,不把当前读数或描述当作身份;重复 ID 或没有 ID 时才附加原始像素边界。列表 `Bounds` 是用于手势的 0–1000 归一化坐标,而 `selector.bounds` 必须是 Android 原始屏幕像素,不能混用,也不能将有损取整的列表坐标反算回去。可点击区域经过裁剪或合并时仍保留原生节点的原始边界。选择器仍逐项精确 AND 匹配;布局改变、原生窗口中有其它同名节点时可能失效或歧义,需要重新观察。`NOT_FOUND` 只表示本次选择器未匹配,不能直接推断控件没有原生语义。等待的目标文本放 `expected_text`,不要塞入身份筛选。
文本动作包括 `set_text`、`set_selection`、`replace_selection`、`ime_enter`。选择区使用 Android UTF-16 索引,不能切开代理对;例如 `A😀B中文` 中 emoji 的范围是 `[1,3]`。`replace_selection` 保留其它文字并将光标移至插入末尾;空白与空字符串按原样传入。密码字段不公开原文,不支持基于隐藏原文替换选区。`ime_enter` 仅提交控件支持的 IME 动作,不代表业务表单已成功提交。 文本动作包括 `set_text`、`set_selection`、`replace_selection`、`ime_enter`。选择区使用 Android UTF-16 索引,不能切开代理对;例如 `A😀B中文` 中 emoji 的范围是 `[1,3]`。`replace_selection` 保留其它文字并将光标移至插入末尾;空白与空字符串按原样传入。密码字段不公开原文,不支持基于隐藏原文替换选区。`ime_enter` 仅提交控件支持的 IME 动作,不代表业务表单已成功提交。
## 手势、组合与验证 ## 手势、组合与验证
+17 -3
View File
@@ -186,6 +186,7 @@
} }
], ],
"default": null, "default": null,
"description": "Exact identity filter, NOT the text to read or wait for. Omit changing readouts; use the observed native_selector instead.",
"title": "Text" "title": "Text"
}, },
"content_desc": { "content_desc": {
@@ -245,6 +246,7 @@
} }
], ],
"default": null, "default": null,
"description": "Exact original Android screen PIXELS [left,top,right,bottom], only when needed to disambiguate. Never copy normalized 0..1000 UI Bounds or convert their rounded values back to pixels. Prefer the observed native_selector.",
"title": "Bounds" "title": "Bounds"
} }
}, },
@@ -623,7 +625,8 @@
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"selector": { "selector": {
"$ref": "#/$defs/ControlSelector" "$ref": "#/$defs/ControlSelector",
"description": "Copy the current observation's native_selector object unchanged. Every supplied attribute is an exact AND filter. Do not add changing text/content_desc or normalized UI Bounds."
}, },
"action": { "action": {
"enum": [ "enum": [
@@ -917,7 +920,8 @@
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"selector": { "selector": {
"$ref": "#/$defs/ControlSelector" "$ref": "#/$defs/ControlSelector",
"description": "Copy the current observation's native_selector object unchanged. Every supplied attribute is an exact AND filter. Do not add changing text/content_desc or normalized UI Bounds."
}, },
"state": { "state": {
"enum": [ "enum": [
@@ -1177,6 +1181,7 @@
} }
], ],
"default": null, "default": null,
"description": "Exact identity filter, NOT the text to read or wait for. Omit changing readouts; use the observed native_selector instead.",
"title": "Text" "title": "Text"
}, },
"content_desc": { "content_desc": {
@@ -1236,6 +1241,7 @@
} }
], ],
"default": null, "default": null,
"description": "Exact original Android screen PIXELS [left,top,right,bottom], only when needed to disambiguate. Never copy normalized 0..1000 UI Bounds or convert their rounded values back to pixels. Prefer the observed native_selector.",
"title": "Bounds" "title": "Bounds"
} }
}, },
@@ -3306,7 +3312,7 @@
} }
}, },
"read_control": { "read_control": {
"description": "[OBSERVE] Read ONE live native control by exact AND selector copied from the current UI. Use resource_id/text/content_desc/class_name and optional package_name/bounds to disambiguate. Returns current range/value, supported native actions, editable selection (UTF-16 offsets), identity token and visible subtree content_signature. Missing or ambiguous controls fail explicitly. Custom canvas controls may expose no range/actions; then use calibrated gestures and visual verification.", "description": "[OBSERVE] Read ONE live native control by exact AND selector copied from the current UI. Copy its native_selector object unchanged; it uses stable identity and original pixel bounds only when needed to disambiguate. UI Bounds are normalized 0..1000 and MUST NOT be used as selector.bounds (exact Android pixels); rounded UI bounds cannot be converted back exactly. Do not add the changing text/content_desc you want to read or wait for as an identity filter. Returns current range/value, supported native actions, editable selection (UTF-16 offsets), identity token and visible subtree content_signature. Missing or ambiguous controls fail explicitly. On NOT_FOUND refresh the UI and selector; it does not prove that native reading is unavailable. Custom canvas controls may expose no range/actions; then use calibrated gestures and visual verification.",
"inputSchema": { "inputSchema": {
"$defs": { "$defs": {
"ControlSelector": { "ControlSelector": {
@@ -3338,6 +3344,7 @@
} }
], ],
"default": null, "default": null,
"description": "Exact identity filter, NOT the text to read or wait for. Omit changing readouts; use the observed native_selector instead.",
"title": "Text" "title": "Text"
}, },
"content_desc": { "content_desc": {
@@ -3397,6 +3404,7 @@
} }
], ],
"default": null, "default": null,
"description": "Exact original Android screen PIXELS [left,top,right,bottom], only when needed to disambiguate. Never copy normalized 0..1000 UI Bounds or convert their rounded values back to pixels. Prefer the observed native_selector.",
"title": "Bounds" "title": "Bounds"
} }
}, },
@@ -3591,6 +3599,7 @@
} }
], ],
"default": null, "default": null,
"description": "Exact identity filter, NOT the text to read or wait for. Omit changing readouts; use the observed native_selector instead.",
"title": "Text" "title": "Text"
}, },
"content_desc": { "content_desc": {
@@ -3650,6 +3659,7 @@
} }
], ],
"default": null, "default": null,
"description": "Exact original Android screen PIXELS [left,top,right,bottom], only when needed to disambiguate. Never copy normalized 0..1000 UI Bounds or convert their rounded values back to pixels. Prefer the observed native_selector.",
"title": "Bounds" "title": "Bounds"
} }
}, },
@@ -3894,6 +3904,7 @@
} }
], ],
"default": null, "default": null,
"description": "Exact identity filter, NOT the text to read or wait for. Omit changing readouts; use the observed native_selector instead.",
"title": "Text" "title": "Text"
}, },
"content_desc": { "content_desc": {
@@ -3953,6 +3964,7 @@
} }
], ],
"default": null, "default": null,
"description": "Exact original Android screen PIXELS [left,top,right,bottom], only when needed to disambiguate. Never copy normalized 0..1000 UI Bounds or convert their rounded values back to pixels. Prefer the observed native_selector.",
"title": "Bounds" "title": "Bounds"
} }
}, },
@@ -4164,6 +4176,7 @@
} }
], ],
"default": null, "default": null,
"description": "Exact identity filter, NOT the text to read or wait for. Omit changing readouts; use the observed native_selector instead.",
"title": "Text" "title": "Text"
}, },
"content_desc": { "content_desc": {
@@ -4223,6 +4236,7 @@
} }
], ],
"default": null, "default": null,
"description": "Exact original Android screen PIXELS [left,top,right,bottom], only when needed to disambiguate. Never copy normalized 0..1000 UI Bounds or convert their rounded values back to pixels. Prefer the observed native_selector.",
"title": "Bounds" "title": "Bounds"
} }
}, },
+34 -16
View File
@@ -640,7 +640,7 @@
"properties": { "properties": {
"selector": { "selector": {
"additionalProperties": false, "additionalProperties": false,
"description": "Every supplied attribute is an exact AND match against a fresh native node.", "description": "Copy the current observation's native_selector object unchanged. Every supplied attribute is an exact AND filter. Do not add changing text/content_desc or normalized UI Bounds.",
"properties": { "properties": {
"resource_id": { "resource_id": {
"anyOf": [ "anyOf": [
@@ -667,6 +667,7 @@
} }
], ],
"default": null, "default": null,
"description": "Exact identity filter, NOT the text to read or wait for. Omit changing readouts; use the observed native_selector instead.",
"title": "Text" "title": "Text"
}, },
"content_desc": { "content_desc": {
@@ -726,6 +727,7 @@
} }
], ],
"default": null, "default": null,
"description": "Exact original Android screen PIXELS [left,top,right,bottom], only when needed to disambiguate. Never copy normalized 0..1000 UI Bounds or convert their rounded values back to pixels. Prefer the observed native_selector.",
"title": "Bounds" "title": "Bounds"
} }
}, },
@@ -908,7 +910,7 @@
"properties": { "properties": {
"selector": { "selector": {
"additionalProperties": false, "additionalProperties": false,
"description": "Every supplied attribute is an exact AND match against a fresh native node.", "description": "Copy the current observation's native_selector object unchanged. Every supplied attribute is an exact AND filter. Do not add changing text/content_desc or normalized UI Bounds.",
"properties": { "properties": {
"resource_id": { "resource_id": {
"anyOf": [ "anyOf": [
@@ -935,6 +937,7 @@
} }
], ],
"default": null, "default": null,
"description": "Exact identity filter, NOT the text to read or wait for. Omit changing readouts; use the observed native_selector instead.",
"title": "Text" "title": "Text"
}, },
"content_desc": { "content_desc": {
@@ -994,6 +997,7 @@
} }
], ],
"default": null, "default": null,
"description": "Exact original Android screen PIXELS [left,top,right,bottom], only when needed to disambiguate. Never copy normalized 0..1000 UI Bounds or convert their rounded values back to pixels. Prefer the observed native_selector.",
"title": "Bounds" "title": "Bounds"
} }
}, },
@@ -1124,6 +1128,7 @@
} }
], ],
"default": null, "default": null,
"description": "Exact identity filter, NOT the text to read or wait for. Omit changing readouts; use the observed native_selector instead.",
"title": "Text" "title": "Text"
}, },
"content_desc": { "content_desc": {
@@ -1183,6 +1188,7 @@
} }
], ],
"default": null, "default": null,
"description": "Exact original Android screen PIXELS [left,top,right,bottom], only when needed to disambiguate. Never copy normalized 0..1000 UI Bounds or convert their rounded values back to pixels. Prefer the observed native_selector.",
"title": "Bounds" "title": "Bounds"
} }
}, },
@@ -1343,6 +1349,7 @@
} }
], ],
"default": null, "default": null,
"description": "Exact identity filter, NOT the text to read or wait for. Omit changing readouts; use the observed native_selector instead.",
"title": "Text" "title": "Text"
}, },
"content_desc": { "content_desc": {
@@ -1402,6 +1409,7 @@
} }
], ],
"default": null, "default": null,
"description": "Exact original Android screen PIXELS [left,top,right,bottom], only when needed to disambiguate. Never copy normalized 0..1000 UI Bounds or convert their rounded values back to pixels. Prefer the observed native_selector.",
"title": "Bounds" "title": "Bounds"
} }
}, },
@@ -1783,12 +1791,12 @@
"type": "function", "type": "function",
"function": { "function": {
"name": "read_control", "name": "read_control",
"description": "[OBSERVE] Read ONE live native control by exact AND selector copied from the current UI. Use resource_id/text/content_desc/class_name and optional package_name/bounds to disambiguate. Returns current range/value, supported native actions, editable selection (UTF-16 offsets), identity token and visible subtree content_signature. Missing or ambiguous controls fail explicitly. Custom canvas controls may expose no range/actions; then use calibrated gestures and visual verification.", "description": "[OBSERVE] Read ONE live native control by exact AND selector copied from the current UI. Copy its native_selector object unchanged; it uses stable identity and original pixel bounds only when needed to disambiguate. UI Bounds are normalized 0..1000 and MUST NOT be used as selector.bounds (exact Android pixels); rounded UI bounds cannot be converted back exactly. Do not add the changing text/content_desc you want to read or wait for as an identity filter. Returns current range/value, supported native actions, editable selection (UTF-16 offsets), identity token and visible subtree content_signature. Missing or ambiguous controls fail explicitly. On NOT_FOUND refresh the UI and selector; it does not prove that native reading is unavailable. Custom canvas controls may expose no range/actions; then use calibrated gestures and visual verification.",
"parameters": { "parameters": {
"properties": { "properties": {
"selector": { "selector": {
"additionalProperties": false, "additionalProperties": false,
"description": "Every supplied attribute is an exact AND match against a fresh native node.", "description": "Copy the current observation's native_selector object unchanged. Every supplied attribute is an exact AND filter. Do not add changing text/content_desc or normalized UI Bounds.",
"properties": { "properties": {
"resource_id": { "resource_id": {
"anyOf": [ "anyOf": [
@@ -1813,7 +1821,8 @@
"type": "null" "type": "null"
} }
], ],
"default": null "default": null,
"description": "Exact identity filter, NOT the text to read or wait for. Omit changing readouts; use the observed native_selector instead."
}, },
"content_desc": { "content_desc": {
"anyOf": [ "anyOf": [
@@ -1868,7 +1877,8 @@
"type": "null" "type": "null"
} }
], ],
"default": null "default": null,
"description": "Exact original Android screen PIXELS [left,top,right,bottom], only when needed to disambiguate. Never copy normalized 0..1000 UI Bounds or convert their rounded values back to pixels. Prefer the observed native_selector."
} }
}, },
"type": "object" "type": "object"
@@ -1890,7 +1900,7 @@
"properties": { "properties": {
"selector": { "selector": {
"additionalProperties": false, "additionalProperties": false,
"description": "Every supplied attribute is an exact AND match against a fresh native node.", "description": "Copy the current observation's native_selector object unchanged. Every supplied attribute is an exact AND filter. Do not add changing text/content_desc or normalized UI Bounds.",
"properties": { "properties": {
"resource_id": { "resource_id": {
"anyOf": [ "anyOf": [
@@ -1915,7 +1925,8 @@
"type": "null" "type": "null"
} }
], ],
"default": null "default": null,
"description": "Exact identity filter, NOT the text to read or wait for. Omit changing readouts; use the observed native_selector instead."
}, },
"content_desc": { "content_desc": {
"anyOf": [ "anyOf": [
@@ -1970,7 +1981,8 @@
"type": "null" "type": "null"
} }
], ],
"default": null "default": null,
"description": "Exact original Android screen PIXELS [left,top,right,bottom], only when needed to disambiguate. Never copy normalized 0..1000 UI Bounds or convert their rounded values back to pixels. Prefer the observed native_selector."
} }
}, },
"type": "object" "type": "object"
@@ -2141,7 +2153,7 @@
"properties": { "properties": {
"selector": { "selector": {
"additionalProperties": false, "additionalProperties": false,
"description": "Every supplied attribute is an exact AND match against a fresh native node.", "description": "Copy the current observation's native_selector object unchanged. Every supplied attribute is an exact AND filter. Do not add changing text/content_desc or normalized UI Bounds.",
"properties": { "properties": {
"resource_id": { "resource_id": {
"anyOf": [ "anyOf": [
@@ -2166,7 +2178,8 @@
"type": "null" "type": "null"
} }
], ],
"default": null "default": null,
"description": "Exact identity filter, NOT the text to read or wait for. Omit changing readouts; use the observed native_selector instead."
}, },
"content_desc": { "content_desc": {
"anyOf": [ "anyOf": [
@@ -2221,7 +2234,8 @@
"type": "null" "type": "null"
} }
], ],
"default": null "default": null,
"description": "Exact original Android screen PIXELS [left,top,right,bottom], only when needed to disambiguate. Never copy normalized 0..1000 UI Bounds or convert their rounded values back to pixels. Prefer the observed native_selector."
} }
}, },
"type": "object" "type": "object"
@@ -2332,7 +2346,8 @@
"type": "null" "type": "null"
} }
], ],
"default": null "default": null,
"description": "Exact identity filter, NOT the text to read or wait for. Omit changing readouts; use the observed native_selector instead."
}, },
"content_desc": { "content_desc": {
"anyOf": [ "anyOf": [
@@ -2387,7 +2402,8 @@
"type": "null" "type": "null"
} }
], ],
"default": null "default": null,
"description": "Exact original Android screen PIXELS [left,top,right,bottom], only when needed to disambiguate. Never copy normalized 0..1000 UI Bounds or convert their rounded values back to pixels. Prefer the observed native_selector."
} }
}, },
"type": "object" "type": "object"
@@ -2419,7 +2435,8 @@
"type": "null" "type": "null"
} }
], ],
"default": null "default": null,
"description": "Exact identity filter, NOT the text to read or wait for. Omit changing readouts; use the observed native_selector instead."
}, },
"content_desc": { "content_desc": {
"anyOf": [ "anyOf": [
@@ -2474,7 +2491,8 @@
"type": "null" "type": "null"
} }
], ],
"default": null "default": null,
"description": "Exact original Android screen PIXELS [left,top,right,bottom], only when needed to disambiguate. Never copy normalized 0..1000 UI Bounds or convert their rounded values back to pixels. Prefer the observed native_selector."
} }
}, },
"type": "object" "type": "object"
+34 -16
View File
@@ -335,6 +335,7 @@
} }
], ],
"default": null, "default": null,
"description": "Exact identity filter, NOT the text to read or wait for. Omit changing readouts; use the observed native_selector instead.",
"title": "Text" "title": "Text"
}, },
"content_desc": { "content_desc": {
@@ -394,6 +395,7 @@
} }
], ],
"default": null, "default": null,
"description": "Exact original Android screen PIXELS [left,top,right,bottom], only when needed to disambiguate. Never copy normalized 0..1000 UI Bounds or convert their rounded values back to pixels. Prefer the observed native_selector.",
"title": "Bounds" "title": "Bounds"
} }
}, },
@@ -1172,7 +1174,7 @@
"properties": { "properties": {
"selector": { "selector": {
"additionalProperties": false, "additionalProperties": false,
"description": "Every supplied attribute is an exact AND match against a fresh native node.", "description": "Copy the current observation's native_selector object unchanged. Every supplied attribute is an exact AND filter. Do not add changing text/content_desc or normalized UI Bounds.",
"properties": { "properties": {
"resource_id": { "resource_id": {
"anyOf": [ "anyOf": [
@@ -1199,6 +1201,7 @@
} }
], ],
"default": null, "default": null,
"description": "Exact identity filter, NOT the text to read or wait for. Omit changing readouts; use the observed native_selector instead.",
"title": "Text" "title": "Text"
}, },
"content_desc": { "content_desc": {
@@ -1258,6 +1261,7 @@
} }
], ],
"default": null, "default": null,
"description": "Exact original Android screen PIXELS [left,top,right,bottom], only when needed to disambiguate. Never copy normalized 0..1000 UI Bounds or convert their rounded values back to pixels. Prefer the observed native_selector.",
"title": "Bounds" "title": "Bounds"
} }
}, },
@@ -1440,7 +1444,7 @@
"properties": { "properties": {
"selector": { "selector": {
"additionalProperties": false, "additionalProperties": false,
"description": "Every supplied attribute is an exact AND match against a fresh native node.", "description": "Copy the current observation's native_selector object unchanged. Every supplied attribute is an exact AND filter. Do not add changing text/content_desc or normalized UI Bounds.",
"properties": { "properties": {
"resource_id": { "resource_id": {
"anyOf": [ "anyOf": [
@@ -1467,6 +1471,7 @@
} }
], ],
"default": null, "default": null,
"description": "Exact identity filter, NOT the text to read or wait for. Omit changing readouts; use the observed native_selector instead.",
"title": "Text" "title": "Text"
}, },
"content_desc": { "content_desc": {
@@ -1526,6 +1531,7 @@
} }
], ],
"default": null, "default": null,
"description": "Exact original Android screen PIXELS [left,top,right,bottom], only when needed to disambiguate. Never copy normalized 0..1000 UI Bounds or convert their rounded values back to pixels. Prefer the observed native_selector.",
"title": "Bounds" "title": "Bounds"
} }
}, },
@@ -1656,6 +1662,7 @@
} }
], ],
"default": null, "default": null,
"description": "Exact identity filter, NOT the text to read or wait for. Omit changing readouts; use the observed native_selector instead.",
"title": "Text" "title": "Text"
}, },
"content_desc": { "content_desc": {
@@ -1715,6 +1722,7 @@
} }
], ],
"default": null, "default": null,
"description": "Exact original Android screen PIXELS [left,top,right,bottom], only when needed to disambiguate. Never copy normalized 0..1000 UI Bounds or convert their rounded values back to pixels. Prefer the observed native_selector.",
"title": "Bounds" "title": "Bounds"
} }
}, },
@@ -2044,12 +2052,12 @@
"type": "function", "type": "function",
"function": { "function": {
"name": "read_control", "name": "read_control",
"description": "[OBSERVE] Read ONE live native control by exact AND selector copied from the current UI. Use resource_id/text/content_desc/class_name and optional package_name/bounds to disambiguate. Returns current range/value, supported native actions, editable selection (UTF-16 offsets), identity token and visible subtree content_signature. Missing or ambiguous controls fail explicitly. Custom canvas controls may expose no range/actions; then use calibrated gestures and visual verification.", "description": "[OBSERVE] Read ONE live native control by exact AND selector copied from the current UI. Copy its native_selector object unchanged; it uses stable identity and original pixel bounds only when needed to disambiguate. UI Bounds are normalized 0..1000 and MUST NOT be used as selector.bounds (exact Android pixels); rounded UI bounds cannot be converted back exactly. Do not add the changing text/content_desc you want to read or wait for as an identity filter. Returns current range/value, supported native actions, editable selection (UTF-16 offsets), identity token and visible subtree content_signature. Missing or ambiguous controls fail explicitly. On NOT_FOUND refresh the UI and selector; it does not prove that native reading is unavailable. Custom canvas controls may expose no range/actions; then use calibrated gestures and visual verification.",
"parameters": { "parameters": {
"properties": { "properties": {
"selector": { "selector": {
"additionalProperties": false, "additionalProperties": false,
"description": "Every supplied attribute is an exact AND match against a fresh native node.", "description": "Copy the current observation's native_selector object unchanged. Every supplied attribute is an exact AND filter. Do not add changing text/content_desc or normalized UI Bounds.",
"properties": { "properties": {
"resource_id": { "resource_id": {
"anyOf": [ "anyOf": [
@@ -2074,7 +2082,8 @@
"type": "null" "type": "null"
} }
], ],
"default": null "default": null,
"description": "Exact identity filter, NOT the text to read or wait for. Omit changing readouts; use the observed native_selector instead."
}, },
"content_desc": { "content_desc": {
"anyOf": [ "anyOf": [
@@ -2129,7 +2138,8 @@
"type": "null" "type": "null"
} }
], ],
"default": null "default": null,
"description": "Exact original Android screen PIXELS [left,top,right,bottom], only when needed to disambiguate. Never copy normalized 0..1000 UI Bounds or convert their rounded values back to pixels. Prefer the observed native_selector."
} }
}, },
"type": "object" "type": "object"
@@ -2151,7 +2161,7 @@
"properties": { "properties": {
"selector": { "selector": {
"additionalProperties": false, "additionalProperties": false,
"description": "Every supplied attribute is an exact AND match against a fresh native node.", "description": "Copy the current observation's native_selector object unchanged. Every supplied attribute is an exact AND filter. Do not add changing text/content_desc or normalized UI Bounds.",
"properties": { "properties": {
"resource_id": { "resource_id": {
"anyOf": [ "anyOf": [
@@ -2176,7 +2186,8 @@
"type": "null" "type": "null"
} }
], ],
"default": null "default": null,
"description": "Exact identity filter, NOT the text to read or wait for. Omit changing readouts; use the observed native_selector instead."
}, },
"content_desc": { "content_desc": {
"anyOf": [ "anyOf": [
@@ -2231,7 +2242,8 @@
"type": "null" "type": "null"
} }
], ],
"default": null "default": null,
"description": "Exact original Android screen PIXELS [left,top,right,bottom], only when needed to disambiguate. Never copy normalized 0..1000 UI Bounds or convert their rounded values back to pixels. Prefer the observed native_selector."
} }
}, },
"type": "object" "type": "object"
@@ -2402,7 +2414,7 @@
"properties": { "properties": {
"selector": { "selector": {
"additionalProperties": false, "additionalProperties": false,
"description": "Every supplied attribute is an exact AND match against a fresh native node.", "description": "Copy the current observation's native_selector object unchanged. Every supplied attribute is an exact AND filter. Do not add changing text/content_desc or normalized UI Bounds.",
"properties": { "properties": {
"resource_id": { "resource_id": {
"anyOf": [ "anyOf": [
@@ -2427,7 +2439,8 @@
"type": "null" "type": "null"
} }
], ],
"default": null "default": null,
"description": "Exact identity filter, NOT the text to read or wait for. Omit changing readouts; use the observed native_selector instead."
}, },
"content_desc": { "content_desc": {
"anyOf": [ "anyOf": [
@@ -2482,7 +2495,8 @@
"type": "null" "type": "null"
} }
], ],
"default": null "default": null,
"description": "Exact original Android screen PIXELS [left,top,right,bottom], only when needed to disambiguate. Never copy normalized 0..1000 UI Bounds or convert their rounded values back to pixels. Prefer the observed native_selector."
} }
}, },
"type": "object" "type": "object"
@@ -2593,7 +2607,8 @@
"type": "null" "type": "null"
} }
], ],
"default": null "default": null,
"description": "Exact identity filter, NOT the text to read or wait for. Omit changing readouts; use the observed native_selector instead."
}, },
"content_desc": { "content_desc": {
"anyOf": [ "anyOf": [
@@ -2648,7 +2663,8 @@
"type": "null" "type": "null"
} }
], ],
"default": null "default": null,
"description": "Exact original Android screen PIXELS [left,top,right,bottom], only when needed to disambiguate. Never copy normalized 0..1000 UI Bounds or convert their rounded values back to pixels. Prefer the observed native_selector."
} }
}, },
"type": "object" "type": "object"
@@ -2680,7 +2696,8 @@
"type": "null" "type": "null"
} }
], ],
"default": null "default": null,
"description": "Exact identity filter, NOT the text to read or wait for. Omit changing readouts; use the observed native_selector instead."
}, },
"content_desc": { "content_desc": {
"anyOf": [ "anyOf": [
@@ -2735,7 +2752,8 @@
"type": "null" "type": "null"
} }
], ],
"default": null "default": null,
"description": "Exact original Android screen PIXELS [left,top,right,bottom], only when needed to disambiguate. Never copy normalized 0..1000 UI Bounds or convert their rounded values back to pixels. Prefer the observed native_selector."
} }
}, },
"type": "object" "type": "object"
+5
View File
@@ -136,11 +136,16 @@ async def test_fallback_observation_renders_the_indexed_ui_list(mock_context, tm
assert isinstance(exec_result.ui_elements_text, str) assert isinstance(exec_result.ui_elements_text, str)
assert exec_result.ui_elements_text == ( assert exec_result.ui_elements_text == (
"[1] Text: 'Settings' | Bounds: [37,50][962,112] | resource_id: 'android:id/title'" "[1] Text: 'Settings' | Bounds: [37,50][962,112] | resource_id: 'android:id/title'"
' | native_selector: {"resource_id": "android:id/title", "class_name": "android.widget.TextView"}'
) )
assert "{'text'" not in exec_result.ui_elements_text assert "{'text'" not in exec_result.ui_elements_text
# State refreshed exactly as the executor's success path does. # State refreshed exactly as the executor's success path does.
assert state.indexed_elements[0]["index"] == 1 assert state.indexed_elements[0]["index"] == 1
assert state.indexed_elements[0]["text"] == "Settings" assert state.indexed_elements[0]["text"] == "Settings"
assert state.indexed_elements[0]["native_selector"] == {
"resource_id": "android:id/title",
"class_name": "android.widget.TextView",
}
assert state.indexed_points == [state.indexed_elements[0]["center"]] assert state.indexed_points == [state.indexed_elements[0]["center"]]
assert state.latest_screenshot != "old.jpg" assert state.latest_screenshot != "old.jpg"
assert state.latest_screenshot.startswith(str(tmp_path)) assert state.latest_screenshot.startswith(str(tmp_path))
@@ -0,0 +1,152 @@
"""Model-visible selectors must round-trip to exact native matching safely."""
from copy import deepcopy
import json
from unittest.mock import AsyncMock
import pytest
from artemis.mcp.native_actions import ControlSelector
from artemis.mcp.native_executor import execute_native
from artemis.utils.ui_filter import filter_ui_hierarchy
from artemis.utils.visualization import format_minimal_list_with_elements
from tests.unit.utils.test_native_control_state import encode_node, hierarchy_node
def native_node(raw):
return {
"resource_id": raw.get("resource-id", ""),
"class_name": raw["class"],
"package_name": raw["package"],
"bounds": [30, 549, 110, 629],
"text": "17:42:20",
"content_desc": "current 17:42:20",
"visible": True,
"actions": ["set_selection"],
}
def exact_native(nodes):
async def inspect(command, args, *, timeout=None):
assert command == "inspect_control", "A failed read must never dispatch input"
matches = [
node
for node in nodes
if all(node.get(key) == value for key, value in args["selector"].items())
]
if len(matches) == 1:
return {"success": True, "execution": "observed", "code": "OK", "node": matches[0]}
return {
"success": False,
"execution": "not_started",
"code": "NOT_FOUND" if not matches else "AMBIGUOUS",
"error": "Exact native selector did not resolve uniquely",
}
return AsyncMock(side_effect=inspect)
@pytest.mark.parametrize("encoding", ["xml", "json"])
@pytest.mark.parametrize("screen", [(720, 1600), (1080, 2400), (1600, 720)])
@pytest.mark.asyncio
async def test_copy_visible_selector_reads_a_changing_value_without_lossy_ui_bounds(
encoding, screen
):
raw = hierarchy_node(
text="17:42:17",
**{
"class": "android.widget.TextView",
"content-desc": "current 17:42:17",
"bounds": "[30,549][110,629]",
"range": None,
"actions": ["set_selection"],
},
)
source = encode_node(raw, encoding)
original = deepcopy(source)
text, elements, _ = format_minimal_list_with_elements([source], *screen)
selector = json.loads(text.split(" | native_selector: ", 1)[1])
assert (
selector
== elements[0]["native_selector"]
== {
"resource_id": raw["resource-id"],
"class_name": raw["class"],
"package_name": raw["package"],
}
)
assert source == original
ControlSelector.model_validate(selector)
native = exact_native([native_node(raw)])
result = await execute_native("read_control", {"selector": selector}, *screen, native)
assert result["success"] and result["node"]["text"] == "17:42:20"
native.assert_awaited_once()
@pytest.mark.parametrize("resource_id", ["", "fixture:id/shared"])
@pytest.mark.asyncio
async def test_no_id_and_repeated_ids_use_original_pixels_to_resolve_each_node(resource_id):
rows = [
hierarchy_node(
text=f"row {i}",
**{
"resource-id": resource_id,
"bounds": f"[30,{549 + i * 100}][110,{629 + i * 100}]",
},
)
for i in range(2)
]
_, elements, _ = format_minimal_list_with_elements(rows, 720, 1600)
nodes = [
native_node(row) | {"bounds": [30, 549 + i * 100, 110, 629 + i * 100]}
for i, row in enumerate(rows)
]
native = exact_native(nodes)
for element, node in zip(elements, nodes, strict=True):
selector = element["native_selector"]
assert selector["bounds"] == node["bounds"]
result = await execute_native("read_control", {"selector": selector}, 720, 1600, native)
assert result["success"] and result["node"]["bounds"] == node["bounds"]
def test_clipped_hit_area_does_not_replace_the_original_native_identity_bounds():
raw = hierarchy_node(
text="partly visible",
**{
"resource-id": "",
"bounds": "[-20,-40][300,60]",
},
)
filtered = filter_ui_hierarchy([raw], screen_width=720, screen_height=1600)
_, elements, _ = format_minimal_list_with_elements(filtered, 720, 1600)
assert elements[0]["bounds"] == [0, 0, 300, 60]
assert elements[0]["native_selector"]["bounds"] == [-20, -40, 300, 60]
ControlSelector.model_validate(elements[0]["native_selector"])
def test_ocr_only_geometry_is_not_presented_as_a_native_selector():
text, elements, _ = format_minimal_list_with_elements(
[{"text": "17:42:17", "class": "[OCR]", "bounds": "[30,549][110,629]"}],
720,
1600,
)
assert "native_selector" not in text
assert "native_selector" not in elements[0]
@pytest.mark.parametrize("action", ["read_control", "control_action"])
@pytest.mark.parametrize("extra", [{"text": "17:42:17"}, {"bounds": [41, 343, 152, 393]}])
@pytest.mark.asyncio
async def test_explicit_stale_text_or_wrong_bounds_are_not_silently_dropped_or_retried(
action, extra
):
raw = hierarchy_node()
native = exact_native([native_node(raw)])
request = {"selector": {"resource_id": raw["resource-id"], **extra}}
if action == "control_action":
request["action"] = "click"
result = await execute_native(action, request, 720, 1600, native)
assert not result["success"] and result["code"] == "NOT_FOUND"
assert result["execution"] == "not_started"
assert native.await_args.args[1]["selector"] == request["selector"]
native.assert_awaited_once()
+8 -3
View File
@@ -78,10 +78,15 @@ def test_minimal_list_shows_hint_for_empty_inputs_and_errors():
text, items, labels = format_minimal_list_with_elements(elements[1:], 1000, 1000) text, items, labels = format_minimal_list_with_elements(elements[1:], 1000, 1000)
lines = text.splitlines() lines = text.splitlines()
assert ( assert (
lines[0] lines[0] == "[1] Hint: 'Search settings' | Bounds: [0,0][100,100]"
== "[1] Hint: 'Search settings' | Bounds: [0,0][100,100] | Editable: true | Value: empty" ' | native_selector: {"class_name": "android.widget.EditText", "package_name": "app", "bounds": [0, 0, 100, 100]}'
" | Editable: true | Value: empty"
)
assert lines[1] == (
"[2] Text: 'abc' | Bounds: [0,200][100,300]"
' | native_selector: {"class_name": "android.widget.TextView", "package_name": "app", "bounds": [0, 200, 100, 300]}'
" | Error: 'Password too short'"
) )
assert lines[1] == "[2] Text: 'abc' | Bounds: [0,200][100,300] | Error: 'Password too short'"
assert len(lines) == 2 and labels == ["1", "2"] assert len(lines) == 2 and labels == ["1", "2"]
assert items[0]["is_hint"] is True and items[0]["text"] == "Search settings" assert items[0]["is_hint"] is True and items[0]["text"] == "Search settings"
assert items[1]["error"] == "Password too short" assert items[1]["error"] == "Password too short"