跳到主要内容

数据截至 (上游 commit 96983c73ed09)

第 1 章 · 会话、轮次与状态机

这章讲什么: 一次任务在 UFO 里被切成哪三层,以及那个「谁来跑下一步」的状态机是怎么写的。这是全框架的骨架,后面所有章节都挂在它上面。


1.1 三层切分:Session / Round / Step

要解决的小问题

一次自动化任务的粒度是模糊的:用户可能连着提三个要求,一个要求要跨两个应用,一个应用里要点十下鼠标。如果不分层,循环终止条件、成本统计、日志归档全都会打成一团。

三层各管什么

一个实例代表结束条件
Session一次完整会话(可多轮对话)用户说完了,或出错
Round一条用户请求状态机报 is_round_end(),或撞上 MAX_STEP
Step一次「截图→问模型→执行」管线四段跑完

骨架代码

BaseSession.run 就是「一直开新轮次直到没了」(ufo/module/basic.py:509-537):

# 示意,非源码 —— 演示 Session 层的形状
while not self.is_finished():
round = self.create_new_round() # 取下一条请求;没有就返回 None
if round is None:
break
result = await round.run() # 把这条请求跑完
self.results.append({"request": round.request, "result": result})

重点看:Session 不关心 agent 是谁、也不关心状态,它只负责「还有没有下一条请求」。真正的状态流转在 Round 里。

一个安全细节

BaseSession.__init__ 会把用户给的 task 名字当作日志目录名,而这个名字可能来自 WebSocket 提交。代码里先 utils.sanitize_task_name 洗一遍,再用 os.path.commonpath 确认解析后的路径没跑出 logs/(ufo/module/basic.py:461-497;洗名函数在 ufo/utils/__init__.py:71 sanitize_task_name)。这是防路径穿越的双保险——先净化、再验证,而不是只做其中一件。


1.2 Round 循环:状态机的实际驱动点

思路

Round 循环短得出奇,一共就四件事:让当前 agent 干活、问下一个状态、问下一个 agent、把状态装回 agent。

真实实现(ufo/module/basic.py:156-180,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)
self.agent.set_state(self.state)

这四行的分量

关键在于 next_agentnext_state 都挂在「状态」上,而不是挂在 agent 上。

换句话说:是「当前处于什么状态」决定了「接下来该谁上场」。这让 HostAgent → AppAgent 的交接不需要任何 if/else 调度器——交接逻辑就写在 AssignHostAgentState 这一个类里。

循环结束条件也很朴素(ufo/module/basic.py:193-201):状态自称 is_round_end(),或者 session 累计步数超过 MAX_STEP(默认 50,见 config/ufo/system.yaml:15)。后者是兜底的死循环保险。

子任务边界的副作用

每当状态自称 is_subtask_end(),Round 会额外做一次快照存档(ufo/module/basic.py:181-186),把当前应用窗口截图归档到 sub_round_id 下。这样日志里能看到「每个子任务收尾时屏幕长什么样」。


1.3 状态机是怎么注册的

要解决的小问题

模型回给你的是一个字符串状态("CONTINUE""FINISH""CONFIRM"…)。你需要把字符串映射到一个类,而且希望加新状态时不用改任何 switch。

做法:装饰器注册 + 单例管理器

# 示意,非源码 —— 演示注册表的形状
class AppAgentStateManager(AgentStateManager):
_state_mapping = {} # 每个子类各有一份自己的映射

@AppAgentStateManager.register # 装饰器把类塞进映射
class FinishAppAgentState(AppAgentState):
@classmethod
def name(cls): return "FINISH" # key 就是这个名字

真实实现:AgentStateManagerSingletonABCMeta 元类保证全局唯一(ufo/agents/states/basic.py:17-49),register 类方法把 state_class.name() 作为 key 写进 _state_mapping(:99-108),get_state 取不到就退回 none_state(:63-80)。

状态基类的四个钩子

AgentState(ufo/agents/states/basic.py:117)只要求实现四件事:

钩子回答什么问题
handle(agent, context)这个状态下要干什么
next_state(agent)干完之后进哪个状态
next_agent(agent)干完之后谁上场
is_round_end() / is_subtask_end()这算轮次/子任务结束吗

默认的 next_state 就是「读 agent 当前 status 字符串,去注册表里查」(ufo/agents/states/app_agent_state.py:91-99)。也就是说,状态跃迁的主控权在模型手里——模型在 JSON 里写 "status": "FINISH",状态机照办。


1.4 两套状态,分别管什么

HostAgent 的状态(ufo/agents/states/host_agent_state.py:21 HostAgentStatus)

状态含义下一步谁上场
CONTINUE还在看桌面、还没定应用还是 HostAgent
ASSIGN已经挑好应用,派活切到 AppAgent
FINISH整轮结束— (is_round_end() 为真)
PENDING需要问用户问题HostAgent
ERROR / FAIL出错 / 判定做不了

交接就发生在 AssignHostAgentState(ufo/agents/states/host_agent_state.py:175-235):handle 里调 agent.create_subagent(context) 造出子 agent,next_agent 返回 agent.get_active_appagent(),next_state 再按子 agent 类型返回 ContinueAppAgentStateContinueOpenAIOperatorState

AppAgent 的状态(ufo/agents/states/app_agent_state.py:30 AppAgentStatus)

状态含义下一步谁上场
CONTINUE继续在这个应用里操作AppAgent
SCREENSHOT重新截图再看AppAgent
CONFIRM这一步敏感,要人确认看用户答复
PENDING要问用户问题AppAgent
FINISH本子任务完成回到 HostAgent
FAIL / ERROR做不了 / 崩了回到 HostAgent

交接全貌

怎么读这张图:方框是状态,箭头上的字是触发条件;虚线框内是同一个 agent 的内部循环。

┌───────────── HostAgent ─────────────┐
│ CONTINUE ──看完桌面还没定──▶ CONTINUE│
│ │ │
│ └── 选中了某应用 ──▶ ASSIGN │
└────────────────┬─────────────────────┘
│ 创建并切到子 agent

┌───────────── AppAgent ──────────────┐
│ CONTINUE ──一步动作──▶ CONTINUE │
│ │ │
│ ├── 敏感动作 ──▶ CONFIRM │
│ └── 子任务做完 ──▶ FINISH │
└────────────────┬─────────────────────┘
│ 回到总管

HostAgent CONTINUE (下一个子任务)

└── 全做完 ──▶ FINISH(轮次结束)

FinishAppAgentState.next_agent 明确返回 agent.host,也就是把控制权还给 HostAgent(ufo/agents/states/app_agent_state.py:127-183)。


1.5 CONFIRM:模型自己拉的手刹

它要解决的小问题

点「发送」和点「加粗」是两码事——前者不可撤销。但程序不可能穷举哪些按钮危险。

思路:让模型自己判定,程序负责拦

prompt 里给了一份敏感动作清单(发邮件/删文件/关窗口/装软件/读浏览器密码等),要求模型在当前这一步属于敏感动作时把 status 写成 CONFIRM,并在 thought 里解释为什么(ufo/prompts/share/base/app_agent.yaml,## Status of the task 一节)。prompt 还反复强调:CONFIRM 只针对当前这一步,不针对未来计划里的步骤。

程序侧的拦截在 ConfirmAppAgentState(ufo/agents/states/app_agent_state.py:300-360):

# 示意,非源码 —— 演示手刹逻辑
if not config.safe_guard: # 配置里关掉了保护,直接放行
await agent.process_resume()
self._confirm = True
else:
self._confirm = agent.process_confirmation() # 弹出终端提示,等人回答
if self._confirm:
await agent.process_resume()

注意执行顺序: 到达 CONFIRM 状态时,动作还没执行;确认后才 process_resume() 真的做。用户拒绝的话,next_state 直接把状态推到 FINISH——不重试、不绕路,干脆收工。

开关在 config/ufo/system.yaml:27SAFE_GUARD(默认 True)。


1.6 黑板:跨 agent 的共享便签

HostAgent 和 AppAgent 是两个不同对象、有各自的私有记忆。它们靠一块黑板(Blackboard)共享东西(ufo/agents/memory/blackboard.py:36)。

黑板上分四栏:

栏目放什么
requests历史用户请求
questions问过用户的问题与答案
trajectories历次动作轨迹
screenshots模型主动要求存下来的截图

最后一栏值得留意:模型在响应里有个 save_screenshot 字段,可以自己决定「这张图以后有用,存进黑板」。也就是说,上下文里放什么图,一部分由模型自己挑

黑板整体会被拼成 prompt 的一段(blackboard_to_prompt,ufo/agents/memory/blackboard.py:274),包括图像。


1.7 几种会话模式

SessionFactory.create_session--mode 分叉(ufo/module/session_pool.py:80):

模式会话类用途
normalSession交互式,人一句它做一段
followerFollowerSession照着计划文件一步步走,不问人
batch_normal批量 FollowerSession一个文件夹的计划批量跑
operatorSession + OpenAIOperatorAgent把动作交给 OpenAI Operator 模型

FollowerSession 有个有意思的差别:它每一轮直接创建 AppAgent 并把状态设成 ContinueAppAgentState,跳过 HostAgent 的应用选择(ufo/module/sessions/session.py:190-225)。因为计划文件里已经写死了在哪个应用干什么。


1.8 代码地图

主题文件路径符号名
会话骨架与路径净化ufo/module/basic.pyBaseSessionBaseSession.run
轮次循环(状态机驱动点)ufo/module/basic.pyBaseRound.runBaseRound.is_finished
状态注册表(单例 + 装饰器)ufo/agents/states/basic.pyAgentStateManagerAgentStateManager.registerSingletonABCMeta
状态基类四钩子ufo/agents/states/basic.pyAgentState
HostAgent 状态与交接ufo/agents/states/host_agent_state.pyHostAgentStatusAssignHostAgentState
AppAgent 状态与回交ufo/agents/states/app_agent_state.pyAppAgentStatusFinishAppAgentStateConfirmAppAgentState
Agent 入口(状态派发)ufo/agents/agent/basic.pyBasicAgent.handleBasicAgent.set_state
共享黑板ufo/agents/memory/blackboard.pyBlackboardBlackboard.blackboard_to_prompt
会话模式分叉ufo/module/session_pool.pySessionFactory.create_session
交互式提问ufo/module/interactor.pyfirst_requestsensitive_step_asker
任务名净化ufo/utils/__init__.pysanitize_task_name