Mika 的 phone_find 工具深解:Agent 如何在长列表里逐屏滚动、直到找到目标
【免费下载链接】Mika
Mika — 你手机上的私人 AI 助理,替你操作 App、上网、把事办成。An on-device AI agent for Android that operates apps and the web to get things done for you.
本篇以 Mika 仓库中phone_find工具的提示词文档(phone_find.md)为主体,完整解读这段发给大模型的工具说明的每一个语义约定,并结合 PhoneFind 实现 与 PhoneAutomationEngine 源码,讲清 Mika 的端侧 Agent 是如何在 Android 无障碍树(accessibility tree)上执行"滚动—观察—匹配—停止"的跨屏查找闭环的。读完后你能掌握:phone_find的参数契约(direction、text/resourceId/role、maxScrolls)、五种停止条件的触发逻辑、匹配结果中ref与父级上下文的含义,以及它与phone_query、phone_collect等相邻工具的分工。
一、工具提示词原文与逐句解读
Mika 的工具提示词统一放在 prompts/tools 目录,每个工具一个 Markdown 文件,由 Prompts.tool() 读取后注入给模型作为该工具的 description。phone_find的完整提示词只有一句话,但每句都是对执行行为的硬约定:
Scroll the current phone app until text, a resource ID, or a role is found in its accessibility tree. Check each resulting screen, stop if the app changes or content stops moving, and return the current ref and parent context. Use for a target further up or down a long list. maxScrolls is a limit, not a fixed action plan.
逐句拆解:
| 原文约定 | 对应实现 |
|---|---|
| 在无障碍树中找 text / resource ID / role | 匹配基于ScreenSnapshot的UiTreeNode,不依赖截图与图像识别 |
| 每一屏都检查 | 每轮循环先observe(withImage = false, recordFrame = false)再匹配,纯文本读屏,不截屏 |
| App 切换或内容停止移动就停 | app_switched与content_unchanged(连续 2 屏指纹相同)两种停止原因 |
| 返回当前 ref 与父级上下文 | 结果携带matches[].ref与parents,供后续phone_tap等动作直接引用 |
| 用于长列表中更上/更下的目标 | 明确定位:不是读屏工具,而是"目标不在当前视野"时的跨屏搜索 |
maxScrolls是上限而非固定计划 | 循环最多limit次滚动,但命中目标或内容稳定会提前退出 |
这段提示词的价值在于:它同时约束了模型的调用时机(长列表找目标才用)和结果解读(maxScrolls不是"必须滚满"),避免模型把 30 次滚动当成固定动作清单。
二、参数契约:方向必传,匹配条件三选一
工具注册在 PhoneAdvancedTools.kt,声明为Risk.Write(会真实滚动界面)、composite = true(内部会发起子动作),中文标签为"滚动查找":
internal object PhoneFind : PhoneTool { override val def = ToolDef("phone_find", Prompts.tool("phone_find"), mapOf( "text" to str("Text or accessibility description to find."), "resourceId" to str("Full or partial Android resource ID."), "role" to str("Full or partial role or class."), "direction" to str("Where the target is: up or down.", listOf("up", "down")), "maxScrolls" to int("Maximum scrolls before returning, 1-200 (default 30).", 1, 200), ), listOf("direction")) }生成的 JSON Schema(见 prompt surface 测试基线)完整参数表如下:
| 参数 | 类型 | 必填 | 取值范围 / 默认 | 说明 |
|---|---|---|---|---|
direction | string,up/down | 是 | 枚举 | 目标相对当前位置的方向:down表示内容更靠下(手指上滑) |
text | string | 否 | 最大 200 字符 | 要查找的文字或无障碍描述,忽略大小写的包含匹配 |
resourceId | string | 否 | 最大 200 字符 | 完整或部分 Android 资源 ID,包含匹配 |
role | string | 否 | 最大 100 字符 | 完整或部分角色/类名,包含匹配 |
maxScrolls | integer | 否 | 1–200,默认 30 | 返回前最多滚动次数(上限,不是计划) |
reason | string | 否 | — | 调用动机备注,不参与执行,由 ToolDef.spec() 自动附加 |
实现中有两处参数校验值得注意:
text、resourceId、role至少要给一个,否则抛出ArgError("text", "give a target")——direction必填但三个匹配条件全空时,工具会在执行入口就拒绝;maxScrolls通过a.int("maxScrolls", 1, 200) ?: 30读取,越界值会被裁剪到 1–200 区间,缺省回落 30。
direction的语义与底层滚动工具一致:phone_scroll.md 同样定义了 "down shows content further down",即内容移动方向而非手指方向;在 ScrollTools.kt 中,direction=down会计算dy = -dist(手指反向滑动),且手机纵向默认滚动优先委托无障碍节点执行、找不到可滚控件时才退化为手势。
三、执行循环:观察—匹配—滚动—判停
PhoneFind.execute 是一个典型的"按界面实际内容推进"的循环(源码注释原话:按界面实际内容推进,直到找到目标或内容停止变化):
for (i in 0..limit) { val read = ctx.session.observe(withImage = false, recordFrame = false) if (read.content["ok"] != JsonPrimitive(true)) { reason = "read_failed"; break } val screen = ctx.session.lastSnapshot if (screen == null) { reason = "read_failed"; break } val currentApp = screen.packageName ?: screen.unreadableApp if (screen.protected || !screen.hasWindow || currentApp == null) { reason = "screen_needs_review"; break } if (app == null) app = currentApp else if (app != currentApp) { reason = "app_switched"; break } result = PhoneAutomationEngine.find(screen, query, 100) if ((result["total"] as? JsonPrimitive)?.intOrNull ?: 0 > 0) { reason = "found"; break } val fingerprint = screen.windows.hashCode().takeIf { screen.windows.isNotEmpty() } ?: screen.elements.hashCode() unchanged = if (fingerprint == previous) unchanged + 1 else 0 if (unchanged >= 2) { reason = "content_unchanged"; break } previous = fingerprint if (i == limit) break val action = sub.run(Part.ToolCall("${ctx.callId}.scroll${i + 1}", "phone_scroll", buildJsonObject { put("direction", direction); put("amount", 3) }.toString())) if (action.content["ok"] != JsonPrimitive(true)) { reason = (action.content["error"] as? JsonPrimitive)?.contentOrNull ?: "scroll_failed" break } scrolled++ }结合提示词中"Check each resulting screen, stop if the app changes or content stops moving"这句话,循环行为可以精确复述为:
- 纯文本读屏:
observe(withImage = false, recordFrame = false)只取无障碍树,不生成截图、不记录帧——查找全程不依赖视觉,这与"found in its accessibility tree"的提示词表述严格对应; - 读屏失败即停:
ok != true或快照缺失时以read_failed结束; - 受保护/无窗口屏幕即停:
protected、hasWindow为 false 或读不到包名时以screen_needs_review结束,把判断权交回模型(这类屏幕通常要走视觉或人工接管); - App 切换即停:首轮记录当前
packageName,之后任一轮发生变化即以app_switched结束——对应提示词 "stop if the app changes",防止滚动把界面带离目标应用后继续盲目翻页; - 内容指纹判停:以
windows(或退化为elements)集合的hashCode作为屏幕指纹,连续 2 屏指纹相同即以content_unchanged结束——对应 "content stops moving",等价于"已经滚到头了"; - 滚动子动作:未命中且未判停时,通过
ctx.sub发起一次phone_scroll子调用,固定amount = 3(Scroll 工具 的默认步长)。子调用编号${ctx.callId}.scroll${i+1}使每次滚动都作为可追溯的子步骤进入动作记录,与单步工具一样享受取消、状态记录与结果上报; - 达到上限:滚满
limit次仍未命中,以scroll_limit结束——这正是提示词 "maxScrolls is a limit, not a fixed action plan" 的含义:30 只是预算,命中或内容稳定都会提前退出。
循环上限写作for (i in 0..limit),意味着最多做maxScrolls + 1次读屏、maxScrolls次滚动:先检查当前屏,再滚。
结果 JSON 的字段
返回结构(源码)由控制字段与最后一次匹配的查询结果合并而成:
| 字段 | 含义 |
|---|---|
ok/found | 是否找到目标(reason == "found") |
stoppedBecause | 停止原因:found、app_switched、content_unchanged、scroll_limit、read_failed、screen_needs_review、scroll_failed或底层滚动返回的具体错误 |
scrolls | 实际发生的滚动次数 |
snapshotId/currentApp | 最后一屏的快照 ID 与应用包名,供后续动作引用 |
total/truncated/matches | 匹配总数、是否截断、匹配明细(见下节) |
四、匹配语义:PhoneAutomationEngine.find 返回什么
每轮匹配都调用 PhoneAutomationEngine.find(),它是phone_find、phone_query、phone_run等工具共用的查询引擎(源码注释:手机界面上的结构化查询和跨屏文本合并,工具和后续本地调用共用这套语义)。匹配规则与返回字段:
- 命中判定:三个条件同时满足才算命中,
null条件视为通配;文字比较的是节点标签(text+description+hintText拼接),ignoreCase包含匹配;resourceId与role同样是忽略大小写的子串匹配——所以提示词里 resourceId/role 都写作 "Full or partial"; ref/actionRef/editRef:命中节点自身有ref就用自身的;若命中的是纯文本叶子(不可点击),则沿clickableParent继承最近可点击祖先的ref作为actionRef,可编辑场景同理给editRef。这保证模型拿到的 ref 能直接用于phone_tap;parents(父级上下文):沿树向上收集节点标签/资源 ID,保留最近 8 层,对应提示词 "return the current ref and parent context"——模型可以据此判断"这个'确定'按钮属于哪个弹窗",避免点错同名控件;- 敏感字段隐藏:
node.secret节点的text不进入标签、不写入name(如密码框内容),测试 queryKeepsParentRelationshipAndHidesSecret 专门验证查询123456(树中一个secret = true的 EditText 的值)时total为 0; - 降级路径:若屏幕没有
windows(结构树不可用),回落到扁平的screen.elements列表做同样的过滤,只是此时没有parents; - 截断:
matches最多返回limit(phone_find固定传 100)条,但total始终是命中总数,truncated标明是否被截断。
五、在 Agent 体系中的位置:调用时机、模型路由与相邻工具
能力提示词中的分工约定
capabilities/phone.md 是电话(手机界面)能力层的总提示词,对phone_find的指引只有一句但边界清晰:
Use phone_find to search further up or down a long list. It observes each screen and stops when the target appears or the content stops changing.
它与同段其他工具的分工是:
phone_query:在当前单屏的大无障碍树里按 text/resourceId/role 查询(实现),不滚动,limit默认 30;phone_find:目标在当前屏之外、需要跨屏滚动时用它;phone_collect:目标不是"找一个控件"而是"把长列表/长聊天的文字采下来",它同样逐屏滚动,但用 appendPage/prependPage 做重叠行去重合并,连续 2 页无新内容(no_new_content)即停;phone_wait/phone_event:等待出现/消失等界面条件,基于无障碍事件而非轮询。
滚动方向在能力提示词中也被反复强调:direction指"你要看的内容在哪",down显示更靠下的内容(见 phone.md 第 62-64 行),并在"没内容移动"时先确认方向没滚反再下"到底"结论。
只允许主模型发起
在 ModelRouter 中,Verb.PhoneFind被列入MAIN_ONLY_VERBS:轻量模型给出该调用时不会被执行,这一步会改派给主模型。注释给出的理由与其他高判断成本工具一致——"放弃、确认、接管、追问、沙箱命令都由主模型决定"。phone_find因涉及"找没找到、要不要继续"的判断而被划入同一档。
测试用例如何验证这段提示词
PhoneAutomationToolsTest.findScrollsUntilContentContainsTarget 用一个假屏幕(前两屏文本为"第 0 页/第 1 页",第 2 屏后出现"目标")驱动PhoneFind.execute,断言恰好对应提示词的三句话:
val result = PhoneFind.execute(args, dispatcher.ctx).content assertEquals("true", result["found"]!!.jsonPrimitive.content) // until ... is found assertEquals("2", result["scrolls"]!!.jsonPrimitive.content) // 滚 2 次后在第 3 屏命中 assertEquals("@e1", result["matches"]!!.jsonArray.single()...) // 返回 current ref另有queryKeepsParentRelationshipAndHidesSecret(父级上下文与 secret 隐藏)、queryReturnsClickableAncestorForTextOnlyChild(纯文本叶子回退到可点击祖先的actionRef)两个用例,覆盖了第四节描述的匹配语义。提示词与实现的对应关系还由 prompt-surface 测试基线 固化:其中第 86 行就是phone_find.md原文,第 87 行是生成后的参数 JSON Schema,任何一边改动都会被一致性测试捕获。
六、实用建议与边界
从提示词与实现可以整理出使用phone_find的几条边界(均以本仓库源码为准):
- 适用场景:目标在长列表中、当前屏看不到,且能用 text / resourceId / role 之一描述。三个条件可组合,例如"某个 resourceId 里含 'row' 的可点击项"。
maxScrolls的取舍:默认 30 次、每次amount=3的滚动,对中等长度列表通常足够;超长列表可调大(上限 200),但每次滚动都是一轮"读屏+匹配",耗时随次数线性增长。找到或内容稳定都会提前返回,不必担心"滚过头"——但方向给反时会一路滚到列表另一端并返回scroll_limit,此时按 phone.md 的指引应先确认方向。- 返回
screen_needs_review的含义:屏幕是受保护窗口(secure/splash 等)或读不出包名,此时无障碍树不可用,应改用视觉类工具(如phone_find_visually,见 phone.md 第 10 行)或人工接管,而不是反复重试phone_find。 - 拿到
matches之后:用matches[].ref(可点击时优先actionRef)配合对应snapshotId调phone_tap;parents用来消歧同名控件。若同一屏有多个候选且位置敏感,改回phone_observe看整屏结构再决定。 - 与
phone_collect的选择:只要"找到并操作"一个目标用phone_find;要"把多屏内容读全"用phone_collect——两者共享PhoneAutomationEngine的滚动-判停骨架,但输出形态完全不同(前者给结构化匹配,后者给按视觉顺序合并的纯文本)。
参考路径
- 工具提示词原文:core/src/main/resources/prompts/tools/phone_find.md
- 工具定义与执行循环:PhoneAdvancedTools.kt
- 匹配引擎:PhoneAutomationEngine.kt
- 单屏查询与多屏采集:PhoneAutomationTools.kt
- 滚动实现与方向语义:ScrollTools.kt、phone_scroll.md
- 能力层分工指引:capabilities/phone.md
- 模型路由(主模型专用工具):ModelRouter.kt
- 测试与基线:PhoneAutomationToolsTest.kt、prompt-surface/tools.txt
【免费下载链接】Mika
Mika — 你手机上的私人 AI 助理,替你操作 App、上网、把事办成。An on-device AI agent for Android that operates apps and the web to get things done for you.
相关推荐
Mika 的 phone_collect 工具:从一行工具说明到逐屏文本采集的完整实现
Mika 的 phone_collect 工具:从一行工具说明到逐屏文本采集的完整实现 本篇以 Mika 面向 Android 的端侧 AI 助理 中的 pho
动态工具发现实战:MCP Gateway & Registry如何帮Agent用自然语言自动找到100+工具
动态工具发现实战:MCP Gateway & Registry如何帮Agent用自然语言自动找到100+工具 MCP Gateway & Registry 是一
从转录到剪辑决定列表:youtube-agent-skill 的 yt-edit 如何自动找出死空与填充词
从转录到剪辑决定列表:youtube agent skill 的 yt edit 如何自动找出死空与填充词 youtube agent skill 是一套 MI
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考