迭代主循环
这个 Stage 在解决什么问题?它负责把“Agent 只会想一次”变成“Agent 能持续工作、持续看结果、持续修正,直到真的完成”。前面的 Stage 只是在把一轮运行准备好;真正的任务推进,发生在这里。它是 Terminus 2 的主循环:每轮先看还能不能继续,再决定要不要压缩上下文、问 LLM(大语言模型)、把动作写进终端、记下结果,并在合适的时候停下。
主流程
- 先进入每一轮,并检查还能不能跑。
_run_agent_loop()(主工作循环)按 episode 一轮轮推进。 这里的意义不是“重复调用函数”,而是给 Agent 一个稳定节奏:看现状、想一步、做一步、再看结果。没有这个循环,Agent 只能做单步反应,没法靠反馈把任务做完。 - 在每轮开头先处理“能不能继续”的问题。
这里会拦三种情况:
- 终端 session(会话)已经死了,直接停
- 任务可能已经完成,要走“双重确认”
- 还没结束,就继续推进 之所以要双重确认,是为了防止 Agent 因为一次误判过早收工。
- 提前处理“上下文会不会爆”的问题。
这一轮里有一条很重要的支线:如果历史太长,就先准备 handoff prompt(交接摘要提示词),把之前做过的事压成更短的版本。 这件事可以两种方式触发:
- 主动探测:还没真报错,就预判上下文快满了
- 被动兜底:
_query_llm()(向 LLM 发起一次请求)真的遇到ContextLengthExceeded,再退回去做摘要 这一步为什么放在主循环里?因为“上下文长度”不是一次性问题,而是随着任务推进不断变大的。
- 把上轮留下的“交接材料”落到正式记录里。
如果前面已经生成了 handoff prompt 和 subagent refs(subagent,小 Agent;refs,指向它们轨迹的引用),这一轮会把它们写进 trajectory(轨迹,Agent 这次试验的完整步骤记录)。 这样做的意义是:压缩不是偷偷发生的,而是被正式记账。后面回放、调试、续跑时,能知道“历史是怎么被折叠过的”。
- 问 LLM 下一步该干什么。
_query_llm()的职责不是单纯拿一段文本回来,而是基于当前上下文,产出“下一步决策”。 这是整个循环的思考点:前面准备材料,后面执行动作,都是为了让这一问更稳、更准。 - 把关键事件打点,并把动作落实到终端。
_record_asciinema_marker()(给终端录像写时间标记)会记下关键节点,方便事后回放。 然后 Agent 把 LLM 决定的动作发到 tmux(可远程控制的终端)里执行。 这里的作用是把“想法”变成“外部世界里的真实变化”。 - 观察结果,并写入轨迹。
终端输出、LLM 决策、完成判断,都会按顺序追加到 trajectory。 这份轨迹不是附属品,而是系统的主记忆:后面要不要总结、要不要确认完成、最后怎么导出,都靠它。
- 在每轮结尾做“是不是该结束”的门控。
任务完成不是第一次判断就 return,而是先把
reg-pending-completion置起来,下一轮再看一次。 如果两次都认为完成,才真正退出。 这个设计很关键:它把“看起来像完成”变成“基本可以确认完成”。 - 持续落盘,避免中途全丢。
_dump_trajectory()(把当前轨迹写到磁盘)和_dump_trajectory_with_continuation_index()(带续写序号地落盘)负责把过程保存下来。 这不是收尾时才做的事,而是循环中的常规动作。因为长任务最怕的是中途崩了,结果什么都没留下。
状态流动
- 写:
reg-n-episodes— 每轮开始时递增,表示现在是第几轮;这样系统后面能知道这次 run 走了多久 - 读:
reg-pending-completion— 本轮会先看上一轮是否已经进入“待确认完成”状态,用来决定这次命中完成时是先挂起还是直接返回 - 写:
reg-pending-completion— 第一次判断任务完成时写入,目的不是立刻结束,而是要求下一轮再确认一次 - 写:
reg-pending-handoff-prompt— 在主动探测到上下文快满,或_query_llm()被ContextLengthExceeded打回时写入;为下一轮准备压缩后的交接文本 - 写:
reg-pending-subagent-refs— 和 handoff prompt 一起写入;记录这次摘要是由哪些 summarization subagent(做摘要的小 Agent)产出的,方便记账 - 读:
reg-pending-subagent-refs— 在本轮中段被消费,把这些摘要 subagent 的引用补进 trajectory,说明历史压缩不是凭空来的 - 读:
reg-pending-handoff-prompt— 在本轮中段被消费,把交接文本变成正式轨迹步骤,必要时触发线性历史切分,让后续上下文更短 - 写:
reg-trajectory-steps— 本 Stage 持续追加步骤:系统记录、用户交接、Agent 决策、完成判断等;它是这次试验的主记录 - 写:
reg-asciinema-markers— 在关键事件处追加时间标记,供后面的录像收尾阶段使用 - 写:
reg-api-request-times— 每次_query_llm()成功走到计时逻辑时追加,用来统计 LLM 调用耗时 - 清:
reg-pending-handoff-prompt— 被 4.6 消费后应清掉,避免下一轮重复插入同一份交接文本 - 清:
reg-pending-subagent-refs— 被 4.5 消费后应清掉,避免同一批摘要引用重复写入轨迹 - 触发下游:
stage-4.1 Loop Entry Gates— 只要没有确认完成、session 没死、也没超出最大轮数,就进入下一轮的入口门控 - 触发下游: [stage-5](stage-5.html) — 当双重确认完成、session 提前死亡,或循环耗尽
_max_episodes时,离开主循环进入收尾汇总
与前后 Stage 的衔接
上游 stage-3 已经把这次 run 的现场清干净,并准备好了初始状态;这个 Stage 开始真正推进任务。它产出的不是单个结果,而是一整段可追踪的运行过程:轨迹、完成状态、摘要交接、计时和标记。下游 stage-5 再把这些过程性产物收拢成最终上下文和输出。
Terminus2.runterminus_2.py:1627–1633 ↗
核心迭代循环的调用转接点
stage 上下文: 该片段只展示
Terminus2.run中一次控制转交:在try块内等待执行self._run_agent_loop(...)。就当前可见代码而言,它的职责是把本地可用输入与实例状态组装后转发给核心循环。由于这是本 stage 给出的首个条目,兄弟关系在此仅体现为:本条目不是循环内部步骤,而是对该循环的显式调用入口。
这段代码在干什么
该代码段在 try 中调用并等待 self._run_agent_loop(...),把四个具名实参一并转发给核心迭代逻辑。输入来源分别是局部变量 initial_prompt、形参 instruction,以及实例状态 self._chat、self.logs_dir。此片段自身没有显式返回值,也没有展示任何直接状态写入;可见副作用仅是发起一次异步调用。
接口 · 参数 / IO
(self, instruction) -> ?
- 参数:
instruction:?— 当前run的实际形参,被以original_instruction=instruction转发给_run_agent_loop - 读状态:
self._chat,self.logs_dir,local: initial_prompt - 返回: 此片段未出现显式
return;可见产出是等待_run_agent_loop(...)执行完成。 - 副作用: 在
try块内发起并等待一次self._run_agent_loop(...)异步调用
执行流
- 进入
try:保护块,为后续异步调用提供异常处理边界。 - 调用并
await self._run_agent_loop(...),按关键字传入initial_prompt=initial_prompt、chat=self._chat、logging_dir=self.logs_dir、original_instruction=instruction。
源码
try:
await self._run_agent_loop(
initial_prompt=initial_prompt,
chat=self._chat,
logging_dir=self.logs_dir,
original_instruction=instruction,
)
Non-obvious 设计决策
- 采用关键字实参而非位置实参转发四个输入,代码形态上提高了调用点的参数对应关系可读性,且能直接从片段中看出每个值被绑定到哪个形参名。
- 将
_run_agent_loop(...)放在try块内而非裸调用,表明此调用被纳入外围异常处理路径;但当前片段未展示具体except/finally行为,因此不能进一步推断其容错策略。
上下游关系
- 调用方: Terminus2.run(当前片段所属方法的后续执行路径)
- 核心被调用: Terminus2._run_agent_loop
- 配置/状态来源: 形参
instruction; 局部变量initial_prompt; 实例状态self._chat; 实例状态self.logs_dir - 结果去向:
_run_agent_loop的执行控制流;try块的后续异常处理/收尾逻辑(未在片段中展开)
Terminus2._run_agent_loopterminus_2.py:1355–1367 ↗
构造轨迹中的代理消息文本
stage 上下文: 该片段位于
_run_agent_loop的核心迭代内部,职责是把本轮 LLM 产物整理成一个可写入轨迹步骤的文本载荷。它不是循环控制或分支决策本身,而是为后续同轮步骤提供统一的message_content局部结果。与同 stage 中负责驱动整轮循环的兄弟逻辑相比,这里只处理代理消息正文的成形策略。
这段代码在干什么
该代码段根据 self._save_raw_content_in_trajectory 的配置,在两种消息表示之间二选一:要么直接采用 llm_response.content,要么把局部变量 analysis 与 plan 组装成结构化文本。其唯一可见产出是对局部变量 message_content 赋值。若 analysis 和 plan 都为空值,则显式把 message_content 设为空字符串。
接口 · 参数 / IO
(self, instruction, chat, logs_dir) -> ?
- 参数:
self._save_raw_content_in_trajectory:bool— 决定轨迹中保存原始 LLM 内容还是保存结构化分析/计划文本;llm_response.content:?— 在启用 raw-content 模式时直接作为消息正文来源;analysis:?— 结构化模式下可选的分析文本片段;plan:?— 结构化模式下可选的计划文本片段 - 读状态:
self._save_raw_content_in_trajectory - 返回: 无显式返回;该片段的实际产出是完成局部变量
message_content的赋值。 - 副作用: 写入局部变量
message_content
执行流
- 先读取
self._save_raw_content_in_trajectory,判断本轮轨迹消息应保存原始内容还是结构化内容。 - 若该开关为真,则直接把
llm_response.content赋给局部变量message_content,不再解析analysis与plan。 - 若该开关为假,则新建局部列表
message_parts,仅在analysis为真值时加入"Analysis: {analysis}"。 - 同一结构化分支中,仅在
plan为真值时加入"Plan: {plan}"。 - 最后将
message_parts以换行符拼接;若列表为空,则显式把message_content设为空字符串。
源码
# Create message content from analysis and plan, or use raw response if raw_content is enabled
if self._save_raw_content_in_trajectory:
# Use the raw LLM response content for SFT data export
message_content = llm_response.content
else:
# Parse into structured format (analysis + plan)
message_parts = []
if analysis:
message_parts.append(f"Analysis: {analysis}")
if plan:
message_parts.append(f"Plan: {plan}")
message_content = "\n".join(message_parts) if message_parts else ""
Non-obvious 设计决策
- 通过
self._save_raw_content_in_trajectory在“原始导出”与“结构化摘要”之间切换,体现的是同一轮响应支持两种保存语义:一类强调保真,另一类强调可读的分析/计划分层。 - 结构化分支对
analysis、plan采用逐项真值检查,而非无条件输出固定标题,因此避免生成空的Analysis:或Plan:段落。 - 当
analysis和plan都未提供时,代码选择把message_content设为空字符串,而不是None或占位文本;这说明下游更可能期望得到始终为字符串的统一类型。
上下游关系
- 调用方: _run_agent_loop` 的当前 region 所在顺序控制流
- 核心被调用: llm_response.content 属性读取; message_parts.append(...); "\n".join(message_parts)
- 配置/状态来源: self._save_raw_content_in_trajectory
- 结果去向: 局部变量
message_content; _run_agent_loop` 后续同轮逻辑可继续读取的消息正文中间结果 - 同类 sibling: 与同 stage 中负责整轮驱动的兄弟逻辑不同,此处不推进 episode,也不处理完成态寄存器,只负责把当前响应整理成文本表示。
Terminus2._record_asciinema_markerterminus_2.py:1984–1985 ↗
asciinema 标记记录空实现
stage 上下文: 该单元是一个两行成员方法,函数体仅包含裸
return。就这段源码本身可证实的事实而言,它没有展开任何记录逻辑,也没有访问实例状态。与同 stage 中承担实际消息构造或循环控制的兄弟函数相比,这里是一个明显更小且无副作用的空实现。
这段代码在干什么
该方法接收一个带类型注解的参数 marker_text: str,但函数体没有引用该参数。源码唯一动作是立即执行 return,因此返回 None。代码中没有出现任何实例属性读写、外部调用或数据结构更新。
接口 · 参数 / IO
(self, marker_text: str) -> None
- 参数:
marker_text:str— 带类型注解的形参;函数体内未被引用 - 返回: 返回 None;函数体仅为立即返回
执行流
- 进入
_record_asciinema_marker(self, marker_text: str)后,不读取任何self._*状态,也不引用形参marker_text。 - 执行裸
return并立即结束函数;之后没有其他语句继续执行。
源码
def _record_asciinema_marker(self, marker_text: str) -> None:
return
Non-obvious 设计决策
- 实现层面保留了带类型注解的形参
marker_text: str,但当前函数体没有消费该输入。 - 函数体采用最小形式的裸
return,源码中未体现任何附加分支、校验、记录或容错逻辑。
上下游关系
- 调用方: 未在该两行源码中体现;就本单元源码本身无法确定调用方
- 核心被调用: 无;函数体内没有任何调用表达式
- 配置/状态来源: 无;函数体不读取配置或实例状态
- 结果去向: 直接结果为向调用方返回 None
- 同类 sibling: 相比
Terminus2._run_agent_loop中可见的消息内容组装与局部变量赋值,本函数没有任何中间产物或状态推进。; 相比Terminus2.run中对核心循环的异步调用转发,本函数既不转发参数,也不发起调用。
Terminus2._dump_trajectoryterminus_2.py:1979–1981 ↗
轨迹落盘包装入口
stage 上下文: 这是一个极小的包装方法,本体只负责把当前续写索引取自
self._summarization_count。相较于真正执行序列化的下层 helper,这里不展开 JSON 生成或文件写出逻辑,只统一传参。
这段代码在干什么
该方法无显式入参,直接读取实例状态 self._summarization_count,并以此作为 continuation index 调用 self._dump_trajectory_with_continuation_index(...)。函数自身没有其他分支、返回构造或属性写入,因此其直接返回值是隐式 None。可见副作用仅是触发一次委托调用;实际轨迹导出行为发生在被调 helper 内部。
接口 · 参数 / IO
(self) -> None
- 参数:
self:?— 提供当前对象上的汇总计数与轨迹导出 helper - 读状态:
self._summarization_count - 返回: 隐式返回 None。
- 副作用: 调用 self._dump_trajectory_with_continuation_index(self._summarization_count)
执行流
- 读取实例属性
self._summarization_count,作为当前轨迹导出的 continuation index。 - 调用
self._dump_trajectory_with_continuation_index(...)完成后续处理;本方法自身不再做额外动作并结束。
源码
def _dump_trajectory(self) -> None:
"""Dump trajectory data to JSON file following ATIF format."""
self._dump_trajectory_with_continuation_index(self._summarization_count)
Non-obvious 设计决策
- 该包装层把 continuation index 的来源固定为
self._summarization_count,从而把“当前应使用哪个续写编号”的选择收敛在一个入口,而不是让调用方重复提供该值。
上下游关系
- 调用方: 未在当前源码片段中展示具体调用方;可确定其设计为供对象内部其他方法直接调用的无参包装入口
- 核心被调用: Terminus2._dump_trajectory_with_continuation_index
- 配置/状态来源: self._summarization_count
- 结果去向: _dump_trajectory_with_continuation_index(...) 的 continuation_index 实参; 被调 helper 内部的后续轨迹导出流程
reg-summarization-count— 读取当前汇总次数作为续写索引
Terminus2._dump_trajectory_with_continuation_indexterminus_2.py:1911–1977 ↗
轨迹快照序列化与续段落盘点
stage 上下文: 该方法是轨迹导出的具体落盘实现,负责把当前内存中的轨迹对象组装为带元数据的 JSON 文件。它与同组的
_dump_trajectory形成包装/执行关系:后者只做一次委托,本函数才实际读取上下文、构造对象并写盘。代码同时处理 linear-history 场景下的续段文件命名与跨文件引用。
这段代码在干什么
该函数接收 continuation_index,读取 self._context、self._trajectory_steps、self._linear_history、self._summarization_count、self._session_id、模型/解析器配置和 self.logs_dir,构造 Trajectory 并写入 JSON 文件。若缺少 self._context,它只记录 warning 并提前返回,不进行任何导出。正常路径下它不会修改实例状态,真正产出是一个写到磁盘的 trajectory.json 或 trajectory.cont-<index>.json 文件;写盘失败时仅记录 error。
接口 · 参数 / IO
(self, continuation_index: int) -> None
- 参数:
continuation_index:int— 决定文件名与续段元数据的续写序号;0表示初始轨迹,正数表示 continuation 文件序号 - 读状态:
self._context,self._context.n_input_tokens,self._context.n_output_tokens,self._context.n_cache_tokens,self._context.cost_usd,self._parser_name,self._temperature,self._llm_kwargs,self._linear_history,self._summarization_count,self._session_id,self._model_name,self._trajectory_steps,self.logs_dir,self.logger,self.name(),self.version() - 返回: 返回
None;实际结果是按条件把轨迹 JSON 写入self.logs_dir下的目标文件,或在前置条件不满足/异常时只记录日志。 - 副作用: 向
self.logger写入 warning / debug / error 日志; 以写模式open(trajectory_path, "w")覆盖式写入轨迹 JSON 文件到磁盘
执行流
- 先检查
self._context是否存在;若为空,则记录"No context available, skipping trajectory dump"warning 并立即return,整个导出流程终止。 - 基于
self._context组装FinalMetrics:直接取n_input_tokens与n_output_tokens,把n_cache_tokens为空时归一为0,而cost_usd则采用self._context.cost_usd if self._context.cost_usd else None,即假值时写成None。 - 初始化
agent_extra为仅含"parser": self._parser_name的字典;随后按条件补充temperature、llm_kwargs,并且只有在self._linear_history为真且continuation_index > 0时才写入continuation_index。 - 按完整条件
self._linear_history and continuation_index < self._summarization_count判断当前轨迹是否还会有后续文件;满足时构造continued_trajectory_ref = f"trajectory.cont-{continuation_index + 1}.json",否则保持为None。 - 构造
Trajectory对象:其中session_id取自self._session_id,agent子对象使用self.name()、self.version() or "unknown"、self._model_name和前面累计的agent_extra,steps直接采用self._trajectory_steps,并附带final_metrics与continued_trajectory_ref。 - 根据文件命名分支选择输出路径:若
self._linear_history为真且continuation_index > 0,写入self.logs_dir / f"trajectory.cont-{continuation_index}.json";否则统一写入self.logs_dir / "trajectory.json"。 - 在
try中以写模式打开目标文件,调用format_trajectory_json(trajectory.to_json_dict())生成格式化 JSON 字符串并写入;成功后记录 debug 日志说明写入位置。 - 若写文件或序列化过程中抛出任意异常,则在
except Exception as e中记录"Failed to dump trajectory: {e}"error,不再向外抛出。
源码
def _dump_trajectory_with_continuation_index(self, continuation_index: int) -> None:
"""Dump trajectory data to JSON file with specified continuation index.
Args:
continuation_index: The continuation index to use for filename and metadata.
For the initial trajectory, use 0.
For the first continuation, use 1, etc.
"""
if not self._context:
self.logger.warning("No context available, skipping trajectory dump")
return
# Construct the trajectory using Pydantic models for validation
final_metrics = FinalMetrics(
total_prompt_tokens=self._context.n_input_tokens,
total_completion_tokens=self._context.n_output_tokens,
total_cached_tokens=self._context.n_cache_tokens or 0,
total_cost_usd=self._context.cost_usd if self._context.cost_usd else None,
)
agent_extra: dict[str, Any] = {
"parser": self._parser_name,
}
if self._temperature is not None:
agent_extra["temperature"] = self._temperature
if self._llm_kwargs:
agent_extra["llm_kwargs"] = self._llm_kwargs
if self._linear_history and continuation_index > 0:
agent_extra["continuation_index"] = continuation_index
# Determine if this trajectory will be continued
# In linear_history mode, when saving during summarization (i.e., continuation_index < _summarization_count),
# this trajectory will have a continuation
continued_trajectory_ref = None
if self._linear_history and continuation_index < self._summarization_count:
# This trajectory segment will be continued in the next file
next_continuation_index = continuation_index + 1
continued_trajectory_ref = f"trajectory.cont-{next_continuation_index}.json"
trajectory = Trajectory(
session_id=self._session_id,
agent=Agent(
name=self.name(),
version=self.version() or "unknown",
model_name=self._model_name,
extra=agent_extra,
),
steps=self._trajectory_steps,
final_metrics=final_metrics,
continued_trajectory_ref=continued_trajectory_ref,
)
# Determine trajectory filename based on continuation index
if self._linear_history and continuation_index > 0:
trajectory_path = (
self.logs_dir / f"trajectory.cont-{continuation_index}.json"
)
else:
trajectory_path = self.logs_dir / "trajectory.json"
try:
with open(trajectory_path, "w") as f:
json_str = format_trajectory_json(trajectory.to_json_dict())
f.write(json_str)
self.logger.debug(f"Trajectory dumped to {trajectory_path}")
except Exception as e:
self.logger.error(f"Failed to dump trajectory: {e}")
Non-obvious 设计决策
- 函数把
self._context视为导出的硬前提:缺失时选择记录 warning 并返回,而不是尝试输出不完整轨迹。这一取舍避免生成缺少 token/cost 汇总的半成品文件。 agent_extra采用条件性补充策略,只在self._temperature is not None、self._llm_kwargs为真、以及self._linear_history and continuation_index > 0时写入对应字段,避免在 JSON 中无差别落入空配置。- 是否声明后续轨迹文件,使用的是精确条件
self._linear_history and continuation_index < self._summarization_count;也就是说只有开启线性历史且当前段号严格小于累计摘要次数时,才在当前文件中写出continued_trajectory_ref,从而把“还有下一段”限制为代码可证明的情形。 - 成本字段采用真值判断
self._context.cost_usd if self._context.cost_usd else None,这意味着0也会被序列化为None;这一归一化与n_cache_tokens or 0的处理并不对称,是代码中一个需要显式注意的序列化约定。 - 写盘外层使用宽泛的
try/except Exception吞掉异常并只记日志,说明该函数把轨迹导出视为尽力而为的旁路能力,而不是允许中断主流程的关键路径。
上下游关系
- 调用方: Terminus2._dump_trajectory
- 核心被调用: FinalMetrics(...); Agent(...); Trajectory(...); self.name(); self.version(); trajectory.to_json_dict(); format_trajectory_json(...); open(trajectory_path, "w"); self.logger.warning(...); self.logger.debug(...); self.logger.error(...)
- 配置/状态来源: self._parser_name; self._temperature; self._llm_kwargs; self._linear_history; self._model_name; self.logs_dir
- 结果去向: 写入
self.logs_dir / "trajectory.json"; 写入self.logs_dir / f"trajectory.cont-{continuation_index}.json"; 在导出成功时向 debug 日志报告目标路径; 在无上下文或异常时向 warning/error 日志报告跳过或失败 - 同类 sibling: 与
_dump_trajectory对照:后者仅触发一次委托;本函数才实际读取上下文字段、构造Trajectory并决定文件名/续段引用。
reg-summarization-count— 比较续段号以决定是否写 continued refreg-trajectory-steps— 把当前轨迹步骤整体序列化入文件