跳到主要内容

UFO² 主循环:Session/Round 与 HostAgent→AppAgent 状态机

30 秒导读: UFO² 是一个操纵 Windows 桌面的 GUI Agent。你给它一句话("把这张图插到 PPT 第二页"), 它就自己截图、找控件、点鼠标、敲键盘去完成。本章讲它最外层的骨架:一次请求是怎么被组织成 一圈一圈的循环、这个循环又是被什么驱动着往前走的——答案是状态模式(state pattern):每一步交给 一个"状态对象"处理,处理完 agent 报一个 status,系统据此算出下一个状态、以及下一步该由谁来做。

本章是 Layer 2 的第一层:只讲单机 agent 的执行骨架与控制流——循环怎么组织、状态怎么流转、 两个 agent 怎么交接。至于"单步内部到底做了哪些事"(策略流水线、记忆黑板)留给 第 02 章;"眼睛"(控件感知与视觉定位)在 第 03 章;"手脚"(混合自动化)在 第 04 章。 跨设备编排(UFO³ Galaxy)见 第 05 章。全景导读见 index


1. 这是什么(零基础也能懂)

一句话定义: UFO² 的运行时,本质是一个被状态机驱动的双层 agent 循环—— 外面一个"总管"(HostAgent)负责挑应用、拆任务,里面一个"干活的"(AppAgent)负责在某个具体应用里 一步步点点点。

它要解决什么问题? 让 LLM 真正"动手用电脑"不是调一次模型就完事的。一个真实任务("整理这份报告并 发邮件")要跨多个应用、要走很多步、每一步都可能需要重新看屏幕、可能要问用户确认、可能失败要退出。 你需要一个**能反复循环、能在多个 agent 之间交接、能干净地知道『什么时候算完了』**的骨架。这就是本章的主角。

三个执行单位,从大到小:

单位白话对应类一次它管什么
Session(会话)你和 UFO 的一整段对话BaseSession / Session多轮请求,直到你说"没了"或触顶
Round(轮)一个用户请求BaseRound从这句话开始,到它被完成/失败
step(步)循环里的一次迭代Round 里的 while 一圈让当前状态处理一次,再算下一状态

一句话直觉/类比: 把它想成红绿灯路口的车流。Round 是"放行一整批车通过路口";每个 step 是 "信号灯变一次";而状态对象就是信号灯——它决定"现在该谁走(哪个 agent)、下一个灯是什么颜色 (下一个状态)"。灯一直变,直到亮起"路口清空"(FINISH),这一批就过完了。


2. 顶层全景(它大概怎么转)

2.1 从命令行到循环:一张总图

先看"一句 python -m ufo -r '...' 是怎么一路变成循环的"。从上到下是包含关系,越往下越小:

命令行 ufo.py
│ SessionFactory.create_session(...) # 按平台/模式造 Session

SessionPool.run_all() ── for session ──► session.run()


┌─── while 未结束 ───────────────┐
│ round = create_new_round() │ ← Session 循环:每圈=一个请求
│ await round.run() │
└────────────┬───────────────────┘

┌─── while 未结束 ──────────────────┐
│ await agent.handle(context) │ ← Round 循环:每圈=一步
│ state = agent.state.next_state() │
│ agent = agent.state.next_agent() │
│ agent.set_state(state) │
└───────────────────────────────────┘

部件一句话职责:

部件干什么在哪
main()解析参数、造 Session、跑 SessionPoolufo/ufo.py:51
SessionFactory按平台(Windows/Linux/Mobile)+模式(normal/follower…)选 Session 子类ufo/module/session_pool.py:72
SessionPool顺序跑一批 Sessionufo/module/session_pool.py:29
BaseSession.run会话循环:反复 create_new_round 直到结束ufo/module/basic.py:509
BaseRound.run本章核心的一步循环(step loop)ufo/module/basic.py:156
AgentState状态对象:处理一步 + 算下一个状态/agentufo/agents/states/basic.py:117
HostAgent / AppAgent两层 agent,本身不含循环,只被状态调用ufo/agents/agent/{host_agent,app_agent}.py

2.2 三层循环,各管一件事

注意:三个 while 层层嵌套,但退出条件各不相同。 分清它们是理解全局的关键。

  • Session 循环(BaseSession.run,ufo/module/basic.py:515):每圈造一个新 Round。 结束条件:用户说完了、total_rounds 触顶、或上一轮进了错误态(is_finished,ufo/module/basic.py:813)。
  • Round 循环(BaseRound.run,ufo/module/basic.py:161):每圈走一步状态机。 结束条件:当前状态 is_round_end() 为真,或 session 总步数触顶(is_finished,ufo/module/basic.py:193)。
  • step:就是 Round 循环体里的那四行——下一节整章都在讲这四行。

3. 核心机制一:一步是怎么走的(step loop)

先把整章最关键的一段真源码放出来——Round 的循环体,只有几行,却是整个控制流的心脏:

# ufo/module/basic.py:161 BaseRound.run
while not self.is_finished():
await self.agent.handle(self.context) # ① 当前状态处理这一步
self.state = self.agent.state.next_state(self.agent) # ② 旧状态算出下一个状态
self.agent = self.agent.state.next_agent(self.agent) # ③ 旧状态算出下一个 agent
self.agent.set_state(self.state) # ④ 把新状态装到(可能已换的)agent 上
if self.state.is_subtask_end(): # 子任务收尾:截图存档
await self.capture_last_snapshot(...)

3.1 思路:为什么用"状态对象"而不是一堆 if/else

一个 GUI agent 每一步的处境很不一样:有时在"总管选应用"、有时在"应用里点按钮"、有时"等用户确认危险 操作"、有时"出错要退出"。如果全写成 if status == "..." 的大分支,会迅速变成不可维护的意大利面。

UFO 用的是经典的 状态模式:把"每种处境该怎么处理、处理完该去哪"各自封装成一个类。循环体永远只做 三件事——让当前状态处理、问它下一个状态是谁、问它下一步谁来干。分支逻辑被分散进各个状态类,循环体 保持极简且永不改动。

3.2 关键细节:handle 先跑,status 才是转移的依据

第 ① 步 agent.handle(context) 会转调到 state.handle(agent, context) (BasicAgent.handle,ufo/agents/agent/basic.py:251)。对"继续"类状态,它会调 agent.process(...) 真正跑一轮:感知屏幕、问 LLM、执行动作,最后把 agent 的 status 字段设成模型这一步的决定 (如 "CONTINUE" / "ASSIGN" / "FINISH",见 HostAgent.process,ufo/agents/agent/host_agent.py:273)。

于是第 ② 步 next_state 才能凭这个新 status 去查表,得到下一个状态。顺序不能反:先处理产生 status, 再据 status 转移。

3.3 关键细节:next_state/next_agent 都问的是"旧状态"

容易看错的一点:第 ②③ 步里 self.agent.state 此刻还是旧状态(第 ④ 步才换)。所以是 "旧状态负责决定去向"——旧状态既算出下一个状态对象,又算出下一步该由哪个 agent 接手 (可能还是自己,也可能从 Host 换成 App、或从 App 换回 Host)。第 ④ 步再把新状态"安装"到那个(可能已经 换过的)agent 身上。这个"旧状态定去向、可换人"的设计,正是两层 agent 能干净交接的基础(见 §5)。

3.4 状态的四个必答问题

每个具体状态类都要回答四个抽象方法(AgentState,ufo/agents/states/basic.py:117):

方法回答的问题谁用它
handle(agent, context)这一步做什么Round 循环第 ① 步
next_state(agent)下一个状态是谁第 ② 步
next_agent(agent)下一步谁来干第 ③ 步
is_round_end() / is_subtask_end()该结束本轮 / 该给子任务收尾了吗循环退出 & 截图判断

4. 核心机制二:状态是怎么注册和查找的

上一节说"凭 status 查表得到状态",这张"表"是怎么来的?这里有三个配合的小机件。

4.1 AgentStatus 枚举:状态的"名字表"

模型每一步只能报出有限几种结果,它们被定成枚举(AgentStatus,ufo/agents/states/basic.py:35):

status含义典型后果
CONTINUE还没完,继续下一步停在"继续"状态,再走一步
FINISH干完了Host:整轮结束;App:交还给 Host
ASSIGN(仅 Host)已选好应用,派给 AppAgent切到 AppAgent
PENDING需要问用户澄清问完再回"继续"
CONFIRM(仅 App)动作敏感,要用户点头确认后继续,否则收尾
SCREENSHOT(仅 App)要重拍/重标注控件再来重标注后回"继续"
FAIL / ERROR失败 / 出错收尾并结束本轮

Host 和 App 各有自己的枚举(HostAgentStatus,ufo/agents/states/host_agent_state.py:21; AppAgentStatus,ufo/agents/states/app_agent_state.py:30)——注意只有 Host 有 ASSIGN,只有 App 有 CONFIRM/SCREENSHOT,这恰好反映两层的职责差异。

4.2 @register 装饰器 + AgentStateManager:字符串 → 状态类

每个状态类头上都挂着一行 @HostAgentStateManager.register(或 App 版)。这个装饰器把状态类按它的 name()(即某个 status 字符串)登记进一张全局映射表:

# ufo/agents/states/basic.py:98 AgentStateManager.register
@classmethod
def register(cls, state_class):
cls._state_mapping[state_class.name()] = state_class # "FINISH" -> FinishHostAgentState
return state_class

查表时按 status 取出对应类、懒加载成单例实例并缓存(get_state,ufo/agents/states/basic.py:63)。 所以 §3.2 里那句"凭 status 查表"落到代码就是:

# HostAgentState.next_state,ufo/agents/states/host_agent_state.py:74
status = agent.status # 例如 "ASSIGN"
state = HostAgentStateManager().get_state(status) # -> AssignHostAgentState 单例
return state

4.3 SingletonABCMeta:每种状态只此一个实例

状态对象本身无状态(逻辑只依赖传入的 agent/context),所以没必要反复 new。UFO 用一个元类把 Manager 做成单例(SingletonMeta/SingletonABCMeta,ufo/agents/states/basic.py:17), 状态实例也在 get_state 里缓存复用。好处:状态转移只是"拿共享单例的引用",零分配、零副作用。 (例外:ConfirmAppAgentState 带了个 _confirm 实例字段记住确认结果,ufo/agents/states/app_agent_state.py:306—— 是全篇少数有可变字段的状态。)


5. 核心机制三:两层 agent 与它们的交接

现在把状态机套到"谁在干活"上。UFO² 是两层:HostAgent(总管)在上,AppAgent(操作工)在下。

5.1 三个 agent 类的分工

角色核心动作文件
BasicAgent抽象基类:memory、status、state、processor 的公共骨架handle / set_state / process(留空)ufo/agents/agent/basic.py:34
HostAgent总管:看整个桌面,选应用、拆子任务、建 AppAgentprocess 决策、create_subagent 派活ufo/agents/agent/host_agent.py:142
AppAgent操作工:锁定在一个应用里点选输入process 跑单应用流水线ufo/agents/agent/app_agent.py:42

BasicAgent 提供了状态机需要的两个约定:status(agent 报给状态机的决定)和 set_state(装状态时还会 断言"这个状态确实是给这类 agent 用的",ufo/agents/agent/basic.py:245,防止把 App 状态错装到 Host 上)。

5.2 Host 侧:从"继续想"到"派活"

HostAgent 的默认状态是 ContinueHostAgentState(ufo/agents/agent/host_agent.py:440)。它的 handleagent.process(ufo/agents/states/host_agent_state.py:128)让总管看一眼桌面、决定下一步, 把 status 设成 ASSIGN(选好应用了)或 CONTINUE/FINISH 等。

若 status 变 ASSIGN,下一步落到 AssignHostAgentState——这是"换人"的关键一跳:

# ufo/agents/states/host_agent_state.py:175 AssignHostAgentState
async def handle(self, agent, context=None):
agent.create_subagent(context) # 造出针对目标应用的 AppAgent

def next_agent(self, agent):
return agent.get_active_appagent() # ③ 下一步交给这个 AppAgent

def next_state(self, agent):
...
return ContinueAppAgentState() # ② 让它进入"继续操作"状态

create_subagent(ufo/agents/agent/host_agent.py:313)会按当前模式和目标应用,经 AgentConfigResolver 解析配置、AgentFactory 造出 AppAgent,登记进 appagent_dict 并设为 _active_appagent。于是下一圈循环,干活的就换成了 AppAgent。

旁注:模式与工厂。 RunningMode(ufo/agents/agent/host_agent.py:34)区分 normal / batch / follower / operator 等运行方式,AgentConfigResolver(:42)据此拼出 agent 名字与 prompt 配置,AgentFactory(:115) 按 agent_type new 出对应类。这层间接让"造哪种操作工"可配置——但不改变循环骨架。

5.3 App 侧:干完就把控制权交还

AppAgent 进入 ContinueAppAgentState 后反复 process(单应用流水线,详见第 02 章),每步更新 status。 一旦模型判定子任务完成、status 变 FINISH,落到 FinishAppAgentState——这是"交还"的关键一跳:

# ufo/agents/states/app_agent_state.py:127 FinishAppAgentState
async def handle(self, agent, context=None):
await self.archive_subtask(context, result) # 把子任务结果记进上下文

def next_agent(self, agent):
return agent.host # ③ 交还给 HostAgent

def next_state(self, agent):
return ContinueHostAgentState() # ② 让总管接着想下一步(normal 模式)

def is_subtask_end(self):
return True # 触发 Round 循环给子任务截图存档

控制权回到 HostAgent 后,总管再看一眼:还有子任务就再 ASSIGN,全做完就 status=FINISH, 落到 FinishHostAgentState,其 is_round_end() 返回 True(ufo/agents/states/host_agent_state.py:106), Round 循环退出——这一轮请求到此结束。

5.4 那些"支线"状态

主线之外,状态机还处理几种插曲(以 App 侧为例):

状态什么时候怎么处理 → 去哪
PendingAppAgentState需要澄清问用户 → 回 ContinueAppAgentState(app_agent_state.py:257)
ConfirmAppAgentState动作敏感(删除/发送)要用户点头;同意→继续,拒绝→收尾(:300)
ScreenshotAppAgentState需重标注控件重拍后回"继续"(:218)
ErrorAppAgentState / FailAppAgentState出错/失败存档 → 交还 Host 的 FinishHostAgentState(:363/:426)

共同暗线:所有"非继续"状态最终都汇向某个结束态或退回主线——这保证循环一定收敛,不会无限空转。 (此外还有全局刹车:session/round 的 is_finished 会在总步数触顶时强制结束, ufo/module/basic.py:200/:820。)


6. 端到端走一次请求

把前面所有零件串起来,追一句真实请求 "帮我把这段文字加粗" 的完整生命周期。 从左到右是时间顺序;缩进表示"谁在干"。

ufo.py main() # ufo/ufo.py:51
└ SessionFactory.create_session(mode="normal") → [Session] # session_pool.py:80,:129
└ SessionPool.run_all() → session.run() # session_pool.py:41

├ _init_agents(): 造 HostAgent(默认态=ContinueHost) # platform_session.py:27
└ while 未结束: create_new_round() → round.run() # basic.py:515 / session.py:106
│ (新 Round 把 HostAgent 设回默认态,当请求人) # session.py:119

▼ Round step loop(basic.py:161)
step1 HostAgent · ContinueHost.handle → process # 看桌面,选中 Word
→ status="ASSIGN" → 下个态 AssignHost,仍是 Host
step2 HostAgent · AssignHost.handle → create_subagent # 造出针对 Word 的 AppAgent
→ next_agent=AppAgent, 下个态 ContinueApp # 换人!
step3 AppAgent · ContinueApp.handle → process # 选中文字 → 点"加粗"
→ status="CONTINUE"(还没完,再来一步)
step4 AppAgent · ContinueApp.handle → process # 确认已加粗
→ status="FINISH" → 下个态 FinishApp
step5 AppAgent · FinishApp.handle → archive_subtask # 记结果, is_subtask_end→截图
→ next_agent=Host, 下个态 ContinueHost # 交还!
step6 HostAgent · ContinueHost.handle → process # 无更多子任务
→ status="FINISH" → 下个态 FinishHost
step7 FinishHost.is_round_end()=True → 退出 Round loop # host_agent_state.py:106

└ round 收尾, session 再问"还有请求吗?" 没有→结束

看懂这张图,你就掌握了 UFO² 单机运行的全部控制流:每一步 = handle 处理 + 按 status 转移; 两次"换人"(Host→App 的 ASSIGN、App→Host 的 FINISH)就是双层协作的全部秘密。 至于每个 process 内部到底发生了什么,是下一章的事。


7. 边界与局限(本章范围)

  • 本章只到"循环与状态如何流转"。 单步内部的策略流水线、记忆黑板 → 第 02 章; 感知/定位 → 第 03 章;动作执行 → 第 04 章
  • 双层是 Windows 特供。 WindowsBaseSession 才有 HostAgent→AppAgent 两层 (ufo/module/sessions/platform_session.py:20);Linux/Mobile 会话没有 HostAgent,单层直连 (LinuxBaseSession,:49)。多设备编排是 UFO³ 的层级,见第 05/06 章。
  • OpenAIOperatorAgentprocess 目前是空壳。 相关处理器被注释掉了 (ufo/agents/agent/app_agent.py:592),operator 路径未完全接通——阅读时别被它误导。
  • 状态转移由 agent 的 status 决定,而 status 由 LLM 输出解析而来。 因此"会不会正确结束"最终依赖模型 判断质量;is_finished 的步数/轮数上限是唯一的硬性兜底(ufo/module/basic.py:813)。

8. 代码地图(导航索引)

主题文件关键符号
命令行入口ufo/ufo.pymain
选 Session(平台×模式)ufo/module/session_pool.pySessionFactory.create_session / _create_windows_session
顺序跑 Sessionufo/module/session_pool.pySessionPool.run_all
会话循环 + 抽象骨架ufo/module/basic.pyBaseSession.run / BaseSession.is_finished
step 循环(本章心脏)ufo/module/basic.pyBaseRound.run / BaseRound.is_finished
Windows 双层初始化ufo/module/sessions/platform_session.pyWindowsBaseSession._init_agents
具体 Session / 建 Roundufo/module/sessions/session.pySession.create_new_round / Session.next_request
状态基类 + 单例元类ufo/agents/states/basic.pyAgentState / AgentStatus / SingletonABCMeta
状态注册与查表ufo/agents/states/basic.pyAgentStateManager.register / AgentStateManager.get_state
Host 状态(继续/派活/结束)ufo/agents/states/host_agent_state.pyContinueHostAgentState / AssignHostAgentState / FinishHostAgentState
App 状态(继续/结束/确认…)ufo/agents/states/app_agent_state.pyContinueAppAgentState / FinishAppAgentState / ConfirmAppAgentState
Agent 抽象骨架ufo/agents/agent/basic.pyBasicAgent.handle / BasicAgent.set_state / AgentRegistry
总管:选应用/建子 agentufo/agents/agent/host_agent.pyHostAgent.process / HostAgent.create_subagent / AgentFactory / AgentConfigResolver / RunningMode
操作工:单应用处理ufo/agents/agent/app_agent.pyAppAgent.process / AppAgent.default_state