Android 上的无人回合:只放开 2 类工具,宁可空手
Andee 是跑在 Android 上的 agent,大脑在本机,靠无障碍服务读元素、点按钮做操作。除了用户开口和闹钟到点这两类回合,他还有第三类:对话静默一段时间后,设备自己给自己发一条 (to yourself ...),让他把刚才那段对话独自读一遍,把该留下的写进本机笔记本。
后台自主回合不是新概念,难的是他出错的方式 ------ 没有用户在场,所以没有报错弹窗、没有「刚才那个不对」、也没有人替你按重试。0.5.1 修的第一件事就落在这里:复盘跟着进程一起死,重启之后不再回来,好不容易恢复的那一次还会读到「0 条可复盘」,然后什么都不做。我把这一轮拆成三个独立问题:权限面、重试语义、账单,各自有各自的写法。
结论先给:这一轮的可用工具集收敛成一条 if,空结果被定义成合法完成,日预算只给「可关掉的那一半」记账。 下面给证据、实现和代价。
一、触发:四个不变量,而不是一个定时器
Scheduler.armSweepIn(context, delayMs) 是唯一的触发入口,setAndAllowWhileIdle,单次,默认 20 分钟(可配 5--240)。围绕这个入口有四条不变量:
| 不变量 | 实现 |
|---|---|
| 一直在说话就不会触发 | 每次回合结束都重新 arm,last write wins |
| 到点也未必该跑 | onSweepTimer 复核 now − lastActivityAt ≥ quiet,不够就 arm 剩余 |
| 一天不能无限跑 | sweepAllowed(cap),默认 12 次/天(可配 0--100,0 = 关) |
| 进程死过也不能漏 | 启动时 catchUpSweep 用 lastSweepAt 与 lastActivityAt 比出「已复盘」和「死之前没来得及」 |
第四行是这一块唯一有设计含量的地方。lastSweepAt 只由 Notebook.noteSweep 写,而它只在一轮复盘真的跑完时被调用 ------ 所以这个字段不是「上次尝试」,是「上次成功」。跟最后活动时间一比,两种情况就分开了:已经复盘过 (不动),和死在半路(补一次)。缺了这个字段,重启后的复盘会把自己当成没跑过,永远重跑同一段对话。
另外,这不是精确闹钟。代码注释写得很清楚:一次安静时段的检查不值得为它申请 exact alarm,那个权限换来的准点,要用一个必须向用户解释的弹窗去买。所以这一轮可能晚到。
二、权限面:一条 if,例外两个
自主回合最大的风险不是他想错,是他想做。约束放在调度入口,用按回合种类传下来的布尔值,不依赖模型配合:
kotlin
// core/.../net/TurnGuards.kt:39
if (silent && !method.startsWith("note.") && method !in GEAR_SILENT_OK) {
throw IllegalStateException(
"A silent turn may only use the notebook (note.*); it must not touch the device: $method",
)
}
private val GEAR_SILENT_OK = setOf("gear.note", "gear.list")
三个决定值得说:
一是为什么是前缀加白名单,不是黑名单。 黑名单在这里必错 ------ 工具表每个版本都在动,新加的 screen.*、app.* 不会有人记得回来补一条。前缀 note. 是「只读写本机笔记」,语义封闭;白名单只有两条,加一条必须改代码,改代码必须过 review。
二是为什么 gate 在 dispatcher,不在 schema。 schema 是静态的,一份定义同时给本机回合、hub 回合、无人回合用,它分不清此刻是谁在调。同一个理由在 noteFold 里出现过一次:折叠只许复盘回合用,普通回合调它会得到一个带理由的拒绝 ------ 判定写在调度器里,因为提示词只是请求。
三是为什么白名单里要留「读」。 只给 gear.note 不给 gear.list,写进去的东西下次读不回来,等于每次重新看一遍屏幕;给了读,就等于承认他手上有一份「这台机器上装了什么」的清单。我选给读。这个决定不是在安全上做出的,是在可用性上做出的,写在这里是因为这是例外,例外就该有名有姓。
三、空结果:合法完成,不是失败
提示词里那句是这条设计的全部依据:真的没产生新东西就别记,空手结束是被允许的,为了记而记只会弄脏笔记本。
代码这边必须配合,否则那句话是空话:
- 没有「产出为零 → 重跑」的分支。复盘跑完就是跑完,
onSweepSpent照记次数。 - 没写、没折、没记,一轮照样结束,历史里只留他自己那段不超过 900 字的独白。
- 若确实什么都没留下,用户侧唯一的变化是设置里那个次数 +1。
反过来说,这一块踩过的坑正好证明「重试」有多危险。上下文将满时的那次同名复盘(harvest)失败时,每试一次就往 history 里追加一条自己的脚手架消息;而这次的防重判据是「history 长度和上次触发时一样吗」------ 每失败一次长度就变一次,判据永远不成立。设备上量到 218 次尝试,间隔约 5 秒,直到 App 重装 。修法是把失败那一轮的脚手架删掉(dropUnfinishedHarvest),让长度回到原处。
所以顺序跟直觉是反的:先定义「不做事」是合法结果,再谈防重。 一个把空结果当失败的系统,天然会长出重试循环,而这个循环在无人回合里没人看得见。
四、账单:只给「可关掉的那一半」记账
无人回合有两种:静默触发的 sweep,和上下文将满时强制的 harvest。两个都花钱,但只有前者计入用户设的日预算:
Only
TurnKind.SWEEPis reported: a harvest is not optional, so counting it against a daily budget the user set for reviews would be misleading.
这句话的意思是,预算的意义是「我愿意让他自己跑几次」,而不是「我愿意花多少钱」。把不可关的那一半算进去,12 次这个数字就失去解释力 ------ 用户跑到 12 次时不知道是他太勤,还是自己被上下文撑爆了。
代价是账单不完整:设置里那行「今天」显示次数和 token,只反映 sweep。这一点我认,因为另一条路是让一个用户改不了的变量去消耗一个用户能改的预算。
五、这里现在不行:代价清单
- 只有本机大脑有这一轮。
startBrain在 hub 分支里提前返回,闹钟根本不 arm。想用这个特性,模型地址得填在本机。 - 少于 6 条消息不跑。 短对话永远不进入复盘。
- 一次最多折 3 个话题,且折叠在模型侧不可逆。 所以 gate 全部写成带理由的拒绝,而不是「失败请重试」------ 复盘被拒又没有理由时,下一次他拿同样的输入去撞同一堵墙。
- 空手也花钱。 不说话、不上字幕、不进聊天记录,钱照花,你看不到过程。
- 会晚到。 不是精确闹钟,Doze 里可能推迟十几分钟。
- 他读的是内存里的对话。 进程一死,这一轮要等下次启动从落盘的记录里重建最多 40 条才补得回来。
六、如果你想给自己的项目加一个无人回合
四条,按重要性排:
- 先定权限面,再写提示词。 无人回合的工具集应该是一个前缀加一张白名单,而这个判断必须发生在 dispatcher,不在 schema ------ schema 不知道现在是谁在调。
- 把「空结果」写进契约。 没有这一条,系统会自己长出重试;重试在无人回合里表现成账单和电量,不是错误。
- 区分「尝试」和「成功」两个时间戳。 只有一个时间戳时,崩溃恢复要么重跑已经做完的事,要么永远不补没做完的事。
- 只给可关掉的那一半记预算。 不可关的部分算进同一个额度,额度这个数字就没人能解释了。
这个项目现在 0.5.3。上面 20 分钟、12 次、6 条、3 个话题这些数字都会变;不该变的是三件事各自的位置 ------ 触发在时间上,权限在调度器里,产出允许为零。