主循环:aiAct 的「计划—执行—重规划」
30 秒导读: 你对 Midscene 说一句"把购物车里的第一件商品删掉",它并不是一次就把整套点击算好。 它进入一个循环:截一张屏 → 让模型只规划下一步动作 → 把这步真的点/输/滚出去 → 把执行结果 塞回去 → 再截屏再规划。这个"看一眼、走一步、再看一眼"的循环,就是 computer-use 的心脏。本章讲透 这颗心脏怎么跳:谁在循环、状态怎么流转、结果怎么回灌、什么时候停。
本章只讲循环编排与状态机。视觉定位怎么把"第一件商品"变成屏幕坐标见 02; standard / custom 两种模型家族的差异见 03;动作最终怎么落到真实设备见 04。全景与阅读地图见 index。
1. 这是什么(零基础也能懂)
- 一句话定义:
aiAct是 Midscene 的自主执行入口——给它一句自然语言目标,它自己反复"规划一步、 执行一步",直到目标达成。 - 它跟"一次点一下"有什么不一样? Midscene 还有
aiTap、aiInput这种一步到位的 API(你已经 知道要点哪、点一次就完)。aiAct不一样:它面对的是需要好几步、而且每步都得先看看屏幕当前长啥样 才能决定下一步的任务。 - 为什么必须是"循环",不能一次算完? 因为 GUI 是不可预测的。你点了"删除",可能弹出一个"确认" 对话框,也可能没有;页面可能加载慢,按钮可能还没出现。模型无法在第一帧就把后面所有步骤都算准—— 它只能"走一步、看一步"。
一句话直觉: 把 aiAct 想成一个边看导航边开车的司机,而不是一个"出发前把每个路口都背下来"
的人。每到一个路口(每规划一轮),它都重新看一眼实时路况(截图),再决定这一下是直行还是转弯。
用起来什么样(最小示例):
// 示意,非源码:一次典型的 aiAct 调用
const agent = new PuppeteerAgent(page);
// 一句自然语言目标,内部会自动循环规划-执行多步
await agent.aiAct('打开设置页,把语言切换成中文,然后保存');
调用方只写一句话。"打开设置 → 找到语言 → 点开下拉 → 选中文 → 点保存"这一连串,以及"保存后是否弹出 提示"这类临场判断,全在循环内部完成。
2. 顶层全景(它大概怎么转)
2.1 三层角色
aiAct 从上到下经过三层。每层只管一件事,把更细的活交给下一层:
| 层 | 符号 | 职责一句话 | 文件 |
|---|---|---|---|
| 入口/模式判定 | Agent.aiAct | 定"这一趟用什么模式跑"(deepThink?带定位规划?重规划上限多少?),再委托下一层 | packages/core/src/agent/agent.ts:870 |
| 重规划循环 | TaskExecutor.runAction | while(true):规划一步 → 转可执行 → 执行 → 回灌 → 判断停不停 | packages/core/src/agent/tasks.ts:431 |
| 单批执行 | TaskRunner.flush | 按 pending 顺序真正跑每个 task,管状态机、截图、错误即停 | packages/core/src/task-runner.ts:252 |
2.2 主线走一遍(高层,不进代码)
怎么读下面这张图:从上往下是一趟 aiAct 的时间线;中间那个方框会循环若干次(每次叫一"轮"
规划);只有模型喊停或撞上限才跳出。
用户: aiAct("删掉购物车第一件商品")
│
▼
┌───────────────────────────┐
│ Agent.aiAct │ 定模式: deepThink? includeLocateInPlanning?
│ (agent.ts:870) │ 命中计划缓存就直接跑 YAML,否则往下
└───────────┬───────────────┘
▼
┌───────────────────────────────────────────────┐
│ TaskExecutor.runAction 的 while(true) │
│ ┌─────────────────────────────────────────┐ │
│ │ ① 截屏 + 让模型规划「下一步」 │◀─┐ 把上一轮
│ │ (Planning task, planImpl) │ │ 的结果
│ │ ② 把规划出的动作转成可执行 task │ │ 回灌进来
│ │ ③ TaskRunner 执行这批 task │ │ (feedback)
│ │ ④ 收集执行反馈 → pendingFeedbackMessage │──┘
│ └─────────────────┬───────────────────────┘ │
│ 模型说 shouldContinuePlanning? │
│ ├─ 否 → break(完成) │
│ └─ 是 → replanCount++,超上限则报错停 │
└───────────────────────────────────────────────┘
│
▼
返回 { output, yamlFlow(供缓存) }
三个关键词先记住,后面逐个展开:
- 一轮(plan round): 一次"截屏 + 规划 + 执行"。一次
aiAct通常有好几轮。 - 回灌(feedback): 上一轮执行完发生了什么,写进
pendingFeedbackMessage,下一轮规划时喂给模型。 - 喊停(shouldContinuePlanning): 模型自己判断"目标达成了没"。它说不用继续,循环就结束。
3. 核心原理(逐个机制,由浅入深)
3.1 模式判定:进循环前先定"这趟怎么跑"
要解决的小问题: 同一个 aiAct,面对不同模型、不同选项,跑法不一样。真正进循环前,Agent.aiAct
先把几个开关定死,后面循环就照这套跑。
两个最关键的开关:
deepThink(深思模式)——是否让模型维护 sub-goal(子目标清单)、多带一张历史截图。注意它不被
custom 规划适配器支持,遇到就降级为 false(agent.ts:897-903);custom vs standard 的区别是
03 的主题,这里只需知道"它可能被静默关掉"。
includeLocateInPlanning(规划时顺带定位)——规划这一步时,是否让模型直接把目标元素的 坐标
一起给出来(而不是规划完再单独跑一次定位)。它的取值只有一行:
// agent.ts:916-918 —— 只有「非深思」且「没有独立定位模型」时,才在规划里顺带定位
const noIndividualLocateModel = planningModel.config.slot === 'default';
const includeLocateInPlanning = !deepThink && noIndividualLocateModel;
重点看:这两个布尔在进循环之前就算好,循环里每一轮都照用,不会中途改。这就是"模式判定"—— 把易变的策略前置固化,让循环体保持简单。
还有一个前置捷径:命中计划缓存就不进循环。 如果这条指令之前跑成功过、缓存里存了对应的 YAML 流程,
aiAct 会直接 runYaml(yaml) 重放,连模型都不调(agent.ts:934-949)。只有没缓存、或缓存重放失败,
才落到下面的 TaskExecutor.action(agent.ts:963)。
重规划上限从哪来: 三级兜底——构造参数 → 环境变量 MIDSCENE_REPLANNING_CYCLE_LIMIT → 适配器默认值
(agent.ts:222-230 的 resolveReplanningCycleLimit)。这个数就是下面循环的"安全阀"。
3.2 重规划循环:while(true) 的五个动作
要解决的小问题: 怎么用一个循环,把"看一步、走一步"变成代码?
思路: 一个无限循环,每一圈干五件事,只有两个出口(模型喊停 / 撞上限)。这是本章的正中心,在
TaskExecutor.runAction(tasks.ts:431),循环体从 while (true)(tasks.ts:520)开始。
一圈里的五个动作:
| 步 | 干什么 | 代码锚点 |
|---|---|---|
| ① 规划一步 | 塞一个 Planning/Plan task 给 runner,内部调 planImpl 让模型出"下一步动作" | tasks.ts:538 session.appendAndRun |
| ② 转可执行 | 把模型给的抽象动作(点这个/输那个)编译成 runner 能跑的 task | tasks.ts:711-722 convertPlanToExecutable |
| ③ 执行这批 | 再 appendAndRun 把动作 task 交给 runner 真跑 | tasks.ts:751 |
| ④ 回灌反馈 | 收集执行结果,写进 pendingFeedbackMessage 供下一轮 | tasks.ts:752-756 |
| ⑤ 判停 | 模型说不用继续就 break;否则 replanCount++,超上限报错 | tasks.ts:792 / 806-811 |
① 规划体里,planImpl 怎么选? 这是 standard / custom 两条路的分岔口:
// tasks.ts:577-580 —— custom 家族用自带 planFn,否则用通用的 XML 规划
const planImpl =
planningModel.adapter.planning.kind === 'custom'
? planningModel.adapter.planning.planFn
: genericXmlPlan;
genericXmlPlan 其实就是 llm-planning.ts 里的 plan 函数(在 workflows/planning/index.ts:4 别名导出)。
两条路各自怎么把截图变成动作,是 03 的正题;本章只需知道:循环体在
这里选好实现,拿回一个 PlanningAIResponse(含 actions、shouldContinuePlanning、memory、
finalizeMessage 等)。
② 为什么规划和执行要分成两次 appendAndRun? 因为模型给的是抽象意图("点登录按钮"),runner
只会跑具体 task。中间必须有一道"编译":convertPlanToExecutable 把每个动作变成可执行 task(这一步
会触发视觉定位,见 02)。编译失败(比如动作参数非法)会直接 appendFailedPlan
终止(tasks.ts:723-730)。
⑤ 两个出口,别搞混:
跑完这一批动作后……
│
├─ planResult.shouldContinuePlanning == false ─→ break (模型认为目标达成)
│
└─ == true ─→ ++replanCount
│
├─ replanCount > 上限 ─→ appendFailedPlan(报错停)
└─ 否则 ─→ 回到循环顶,再规划一轮
模型喊停(tasks.ts:792-794)是正常完 成;撞上限(tasks.ts:808-811)是失败保护,会提示你把
replanningCycleLimit 调大。
3.3 回灌:把"刚才发生了什么"喂回下一轮
要解决的小问题: 模型规划第 N+1 步时,凭什么知道第 N 步干成了没?靠回灌。
思路: 每跑完一批动作,把这批 task 报上来的反馈汇总成一句话,连同当前时间,写进
conversationHistory.pendingFeedbackMessage。下一轮规划时,这句话会作为"上一步已执行、这是最新截图"
的上下文喂给模型。
成功路径——汇总本批新增 task 的 planningFeedback:
// tasks.ts:752-756 —— 只取本轮新跑的那几个 task(slice(taskCountBeforeRun))的反馈
this.setPendingFeedbackMessage(
conversationHistory,
initialTimeString,
this.collectPlanningFeedback(runner.tasks.slice(taskCountBeforeRun)),
);
collectPlanningFeedback(tasks.ts:234-241)把每个 task 的 planningFeedback 拼起来;关键防线:
每条反馈先过 truncatePlanningFeedback 截到 500 字符(tasks.ts:83-92),防止某个动作 吐出一大坨
stdout 把下一轮的上下文撑爆。
失败路径——如果这批动作抛错,回灌的就是错误信息(tasks.ts:757-765),让模型下一轮"知道刚才
出错了、换个招"。注意执行出错默认不立刻终止整个 aiAct:错误计数 errorCountInOnePlanningLoop
累加,只有一轮里错超过 5 次(maxErrorCountAllowedInOnePlanningLoop,tasks.ts:776-781)才真的放弃。
写入口只有一个: 所有对 pendingFeedbackMessage 的写,都走 setPendingFeedbackMessage
(tasks.ts:220-228),它统一加"时间前缀"。这样"当前几点"这个上下文永远一致——GUI agent 里
时间感很重要(判断"加载转圈是不是卡住了")。
回灌的消费在规划侧: 下一轮 plan 里,若 pendingFeedbackMessage 非空,就把它拼进给模型的那条
user 消息,并随即清空(llm-planning.ts:183-201 的 resetPendingFeedbackMessageIfExists)。一条
反馈只被消费一次。
一个巧妙的容错(#2529): 准备重规划,意味着刚跑的这批没把任务干完。那这批里"命中定位缓存" 的步骤,产出的元素就是可疑的。
invalidateFailedCacheHitLocates(tasks.ts:409-429,循环内 803 行 调用)把这些缓存标记为 stale,让下一轮对同一 prompt 的重新定位原地替换坏条目,而不是追加一条 会在下次运行时被优先命中的"投毒"副本。
3.4 单批执行:TaskRunner.flush 的状态机
要解决的小问题: 上面说的"跑一批 task",具体怎么跑?这落在 TaskRunner.flush(task-runner.ts:252)。
思路: 从第一个 pending 的 task 开始,顺序往下,一个个跑;每个 task 有明确的状态流转;一个出错
就停下,把后面全部标 cancelled。
task 的状态流转:
pending ──▶ running ──┬─▶ finished (executor 正常返回)
│
└─▶ failed (executor 抛错)
│
▼
后面所有 task ──▶ cancelled
flush 找到第一个 pending(task-runner.ts:265),然后 while (taskIndex < tasks.length) 逐个执行
(task-runner.ts:280)。每个 task 跑完 Object.assign(task, returnValue) 落结果、置 finished
(task-runner.ts:359-360);一旦 executor 抛错,置 failed 并 break(task-runner.ts:366-378),
循环外把剩余 task 全标 cancelled(task-runner.ts:383-393)。错误即停、绝不带病继续。
三种 task type——runner 只认这三类(task-runner.ts:301 的断言把关):
| type | 干什么 | 谁产生它 |
|---|---|---|
Planning | 让模型规划下一步(子类型 Plan);或做定位(子类型 Locate) | 循环体 tasks.ts:538 |
Action Space | 真实动作:点击/输入/滚动/等待…… | convertPlanToExecutable 编译动作 |
Insight | 信息抽取:Query / Assert / WaitFor 等 | aiQuery / aiAssert 等旁路 |
Planning/Locate 跑完,runner 会把它的 output 记进 previousFindOutput,传给紧随其后的动作 task 当
element(task-runner.ts:336-339、317-321)——"先定位、后动作"就是这样串起来的。
截图与缓存,两条不能忽略的细节:
1)UI 上下文有 300ms 缓存,但 Insight 强制刷新。 每个 task 跑前要拿一次 UI 上下文(截图 + 元素树)。
频繁截图很贵,所以 getUiContext 给结果加了 300ms 的 TTL 缓存:
// task-runner.ts:130-137 —— 300ms 内的上一张 UI 上下文可直接复用
const shouldReuse =
!options?.forceRefresh &&
this.lastUiContext &&
now - this.lastUiContext.capturedAt <= UI_CONTEXT_CACHE_TTL_MS; // 300
但 Insight(Query/Assert/WaitFor)永远强制刷新(task-runner.ts:311 的 forceRefresh),因为
"页面现在是什么状态"必须是最新的——刚点完一个按钮就断言结果,不能用点之前的旧截图。
2)after-calling 截图录制。 一批 task 的最后一个跑完后,会再截一张"事后"截图挂到 task 的
recorder 上(task-runner.ts:350-357),供报告回放看"这步做完屏幕变成了啥"。
session 是薄封装: 循环里用的 session.appendAndRun 只是 ExecutionSession 对 runner 的转发
(execution-session.ts:42-47 → runner.appendAndFlush),一次 append + 一次 flush。allowWhenError:true
让规划 task 即便 runner 处于 error 态也能追加(tasks.ts:699-701),这样出错后还能补一轮规划去补救。
3.5 ConversationHistory:历史、记忆、子目标,以及防溢出
要解决的小问题: 循环跑很多轮,给模型的对话会越堆越长。既要让模型"记得前因后果",又不能把上下文
窗口撑爆。ConversationHistory(conversation-history.ts:8)就是这本"随身账本"。
它同时装四样东西:
| 内容 | 字段/方法 | 作 用 |
|---|---|---|
| 原始对话消息 | messages / append | 喂给模型的 user/assistant 消息序列 |
| 记忆 | memories / appendMemory memoriesToText | 跨轮的要点("已登录""密码是 X") |
| 子目标 | subGoals / mergeSubGoals subGoalsToText | 仅 deepThink 模式:把大目标拆成清单并逐个勾掉 |
| 历史步骤日志 | historicalLogs / appendHistoricalLog | 非 deepThink 模式:记"已执行过哪些步" |
子目标 vs 历史日志是二选一的:deepThink 开时用子目标清单,否则用历史日志(llm-planning.ts:170-172)。
循环体每轮开头会把这两样(若有)读成文本塞进 Planning task 的参数(tasks.ts:532-536)。
防上下文溢出——两道闸:
第一道:压缩老消息。 每次规划前调 compressHistory(50, 20)(llm-planning.ts:223):消息数超过 50 就
把最老的一批换成一句占位符"(N 条历史消息已省略)",只留最近 20 条:
// conversation-history.ts:343-352 —— 超阈值就用一句占位符替掉最老的消息
compressHistory(threshold, keepCount) {
if (this.messages.length <= threshold) return false;
const omittedCount = this.messages.length - keepCount;
const omittedPlaceholder = {
role: 'user',
content: `(${omittedCount} previous conversation messages have been omitted)`,
};
// ……只保留最近 keepCount 条,前面塞一句占位符
}
第二道:限制图片数。 snapshot(maxImages)(conversation-history.ts:53)从最新往回数,超过上限的历史
截图替换成文本"(image ignored due to size optimization)"。imagesIncludeCount 由模式决定:deepThink 带
2 张、否则 1 张(agent.ts:962)。截图是 token 大户,这一刀砍得最狠。
每轮往账本里加什么: 规划成功后,模型的原始回复作为一条 assistant 消息 append 进去
(llm-planning.ts:333-341),memory/log/子目标更新也在同一处落账(llm-planning.ts:307-331)。
于是账本随循环滚动增长,再被上面两道闸压住体积。
4. 巧妙之处(可借鉴的技术)
- 规划与执行彻底解耦,中间夹一层"编译"。 模型只吐抽象意图,
convertPlanToExecutable把 它编译成 runner 能跑的 task(tasks.ts:711)。好处:模型不用懂设备细节,runner 不用懂自然语言,视觉定位被 夹在编译层里独立演进。 - 回灌只有单一写入口 + 硬截断。 所有反馈经
setPendingFeedbackMessage统一加时间前缀 (tasks.ts:220),且先过 500 字符截断(tasks.ts:83)。既保证上下文一致,又堵死"长 stdout 撑爆 上下文"这个坑。 - 错误分级,不一刀切。 单个 task 出错 → runner 停这批但 aiAct 不死,靠错误回灌让模型换招;一轮内
错超 5 次才放弃(
tasks.ts:776);编译失败或撞重规划上限才是硬失败。容错和止损分层。 - UI 上下文 300ms 缓存,但 Insight 强制新鲜。 用一个 TTL(
task-runner.ts:20,130)在"省截图开销"和 "断言必须看最新屏"之间划清界线。 - 失败重规划时主动清理定位缓存(#2529)。 没干完的那批里,命中缓存的定位被标 stale
(
tasks.ts:409),避免坏结果污染后续运行。这是"缓存 + 自主循环"组合下很容易踩、也很容易忽略的坑。
5. 边界与局限
- 重规划有硬上限。 复杂任务可能撞
replanningCycleLimit(tasks.ts:808)。会明确报错让你调大, 而不是无限跑下去。 - deepThink 对 custom 家族无效。 传了也会被静默降级为 false(
agent.ts:897-903)——子目标机制只在 standard/XML 规划路径上生效。 - 压缩是"截断式"而非"摘要式"。
compressHistory直接丢老消息换占位符(conversation-history.ts:343), 不做语义摘要,极长任务里早期细节会真的丢失,靠memories兜住关键信息。 - 本章不覆盖的: 抽象动作怎么变成屏幕坐标(见 02)、standard vs custom 规划 实现(见 03)、动作怎么落到真实设备(见 04)。
6. 代码地图(导航索引)
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| aiAct 入口 / 模式判定 | packages/core/src/agent/agent.ts | Agent.aiAct |
| 重规划上限三级兜底 | packages/core/src/agent/agent.ts | resolveReplanningCycleLimit |
重规划主循环 while(true) | packages/core/src/agent/tasks.ts | TaskExecutor.runAction |
| 动作编译成可执行 task | packages/core/src/agent/tasks.ts | convertPlanToExecutable / TaskBuilder.build |
| 反馈汇总(供下一轮) | packages/core/src/agent/tasks.ts | collectPlanningFeedback |
| 反馈截断防溢出 | packages/core/src/agent/tasks.ts | truncatePlanningFeedback |
| 反馈统一写入口 | packages/core/src/agent/tasks.ts | setPendingFeedbackMessage |
| 失败重规划清理定位缓存 | packages/core/src/agent/tasks.ts | invalidateFailedCacheHitLocates |
| 单批执行 / 状态机 | packages/core/src/task-runner.ts | TaskRunner.flush |
| UI 上下文 300ms 缓存 | packages/core/src/task-runner.ts | getUiContext / UI_CONTEXT_CACHE_TTL_MS |
| after-calling 截图录制 | packages/core/src/task-runner.ts | attachRecorderItem / captureScreenshot |
| session 薄封装 | packages/core/src/agent/execution-session.ts | ExecutionSession.appendAndRun |
| 规划实现(planImpl 默认) | packages/core/src/ai-model/llm-planning.ts | plan(别名 genericXmlPlan) |
| 历史 / 记忆 / 子目标账本 | packages/core/src/ai-model/conversation-history.ts | ConversationHistory |
| 压缩防溢出 | packages/core/src/ai-model/conversation-history.ts | compressHistory |
| 历史截图数量裁剪 | packages/core/src/ai-model/conversation-history.ts | snapshot |