Terminus 2 手册

迭代主循环

stage-4Iteration Loop5 个函数★ Core

这个 Stage 在解决什么问题?它负责把“Agent 只会想一次”变成“Agent 能持续工作、持续看结果、持续修正,直到真的完成”。前面的 Stage 只是在把一轮运行准备好;真正的任务推进,发生在这里。它是 Terminus 2 的主循环:每轮先看还能不能继续,再决定要不要压缩上下文、问 LLM(大语言模型)、把动作写进终端、记下结果,并在合适的时候停下。

主流程

  1. 先进入每一轮,并检查还能不能跑。

    _run_agent_loop()(主工作循环)按 episode 一轮轮推进。 这里的意义不是“重复调用函数”,而是给 Agent 一个稳定节奏:看现状、想一步、做一步、再看结果。没有这个循环,Agent 只能做单步反应,没法靠反馈把任务做完。

  2. 在每轮开头先处理“能不能继续”的问题。

    这里会拦三种情况:

    • 终端 session(会话)已经死了,直接停
    • 任务可能已经完成,要走“双重确认”
    • 还没结束,就继续推进 之所以要双重确认,是为了防止 Agent 因为一次误判过早收工。
  3. 提前处理“上下文会不会爆”的问题。

    这一轮里有一条很重要的支线:如果历史太长,就先准备 handoff prompt(交接摘要提示词),把之前做过的事压成更短的版本。 这件事可以两种方式触发:

    • 主动探测:还没真报错,就预判上下文快满了
    • 被动兜底:_query_llm()(向 LLM 发起一次请求)真的遇到 ContextLengthExceeded,再退回去做摘要 这一步为什么放在主循环里?因为“上下文长度”不是一次性问题,而是随着任务推进不断变大的。
  4. 把上轮留下的“交接材料”落到正式记录里。

    如果前面已经生成了 handoff prompt 和 subagent refs(subagent,小 Agent;refs,指向它们轨迹的引用),这一轮会把它们写进 trajectory(轨迹,Agent 这次试验的完整步骤记录)。 这样做的意义是:压缩不是偷偷发生的,而是被正式记账。后面回放、调试、续跑时,能知道“历史是怎么被折叠过的”。

  5. 问 LLM 下一步该干什么。

    _query_llm() 的职责不是单纯拿一段文本回来,而是基于当前上下文,产出“下一步决策”。 这是整个循环的思考点:前面准备材料,后面执行动作,都是为了让这一问更稳、更准。

  6. 把关键事件打点,并把动作落实到终端。

    _record_asciinema_marker()(给终端录像写时间标记)会记下关键节点,方便事后回放。 然后 Agent 把 LLM 决定的动作发到 tmux(可远程控制的终端)里执行。 这里的作用是把“想法”变成“外部世界里的真实变化”。

  7. 观察结果,并写入轨迹。

    终端输出、LLM 决策、完成判断,都会按顺序追加到 trajectory。 这份轨迹不是附属品,而是系统的主记忆:后面要不要总结、要不要确认完成、最后怎么导出,都靠它。

  8. 在每轮结尾做“是不是该结束”的门控。

    任务完成不是第一次判断就 return,而是先把 reg-pending-completion 置起来,下一轮再看一次。 如果两次都认为完成,才真正退出。 这个设计很关键:它把“看起来像完成”变成“基本可以确认完成”。

  9. 持续落盘,避免中途全丢。

    _dump_trajectory()(把当前轨迹写到磁盘)和 _dump_trajectory_with_continuation_index()(带续写序号地落盘)负责把过程保存下来。 这不是收尾时才做的事,而是循环中的常规动作。因为长任务最怕的是中途崩了,结果什么都没留下。

状态流动

与前后 Stage 的衔接

上游 stage-3 已经把这次 run 的现场清干净,并准备好了初始状态;这个 Stage 开始真正推进任务。它产出的不是单个结果,而是一整段可追踪的运行过程:轨迹、完成状态、摘要交接、计时和标记。下游 stage-5 再把这些过程性产物收拢成最终上下文和输出。

函数细节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._chatself.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(...) 异步调用

执行流

  1. 进入 try: 保护块,为后续异步调用提供异常处理边界。
  2. 调用并 await self._run_agent_loop(...),按关键字传入 initial_prompt=initial_promptchat=self._chatlogging_dir=self.logs_diroriginal_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,要么把局部变量 analysisplan 组装成结构化文本。其唯一可见产出是对局部变量 message_content 赋值。若 analysisplan 都为空值,则显式把 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

执行流

  1. 先读取 self._save_raw_content_in_trajectory,判断本轮轨迹消息应保存原始内容还是结构化内容。
  2. 若该开关为真,则直接把 llm_response.content 赋给局部变量 message_content,不再解析 analysisplan
  3. 若该开关为假,则新建局部列表 message_parts,仅在 analysis 为真值时加入 "Analysis: {analysis}"
  4. 同一结构化分支中,仅在 plan 为真值时加入 "Plan: {plan}"
  5. 最后将 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 在“原始导出”与“结构化摘要”之间切换,体现的是同一轮响应支持两种保存语义:一类强调保真,另一类强调可读的分析/计划分层。
  • 结构化分支对 analysisplan 采用逐项真值检查,而非无条件输出固定标题,因此避免生成空的 Analysis:Plan: 段落。
  • analysisplan 都未提供时,代码选择把 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;函数体仅为立即返回

执行流

  1. 进入 _record_asciinema_marker(self, marker_text: str) 后,不读取任何 self._* 状态,也不引用形参 marker_text
  2. 执行裸 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)

执行流

  1. 读取实例属性 self._summarization_count,作为当前轨迹导出的 continuation index。
  2. 调用 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 内部的后续轨迹导出流程
寄存器交互
Terminus2._dump_trajectory_with_continuation_indexterminus_2.py:1911–1977 ↗

轨迹快照序列化与续段落盘点

stage 上下文: 该方法是轨迹导出的具体落盘实现,负责把当前内存中的轨迹对象组装为带元数据的 JSON 文件。它与同组的 _dump_trajectory 形成包装/执行关系:后者只做一次委托,本函数才实际读取上下文、构造对象并写盘。代码同时处理 linear-history 场景下的续段文件命名与跨文件引用。

这段代码在干什么

该函数接收 continuation_index,读取 self._contextself._trajectory_stepsself._linear_historyself._summarization_countself._session_id、模型/解析器配置和 self.logs_dir,构造 Trajectory 并写入 JSON 文件。若缺少 self._context,它只记录 warning 并提前返回,不进行任何导出。正常路径下它不会修改实例状态,真正产出是一个写到磁盘的 trajectory.jsontrajectory.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 文件到磁盘

执行流

  1. 先检查 self._context 是否存在;若为空,则记录 "No context available, skipping trajectory dump" warning 并立即 return,整个导出流程终止。
  2. 基于 self._context 组装 FinalMetrics:直接取 n_input_tokensn_output_tokens,把 n_cache_tokens 为空时归一为 0,而 cost_usd 则采用 self._context.cost_usd if self._context.cost_usd else None,即假值时写成 None
  3. 初始化 agent_extra 为仅含 "parser": self._parser_name 的字典;随后按条件补充 temperaturellm_kwargs,并且只有在 self._linear_history 为真且 continuation_index > 0 时才写入 continuation_index
  4. 按完整条件 self._linear_history and continuation_index < self._summarization_count 判断当前轨迹是否还会有后续文件;满足时构造 continued_trajectory_ref = f"trajectory.cont-{continuation_index + 1}.json",否则保持为 None
  5. 构造 Trajectory 对象:其中 session_id 取自 self._session_idagent 子对象使用 self.name()self.version() or "unknown"self._model_name 和前面累计的 agent_extrasteps 直接采用 self._trajectory_steps,并附带 final_metricscontinued_trajectory_ref
  6. 根据文件命名分支选择输出路径:若 self._linear_history 为真且 continuation_index > 0,写入 self.logs_dir / f"trajectory.cont-{continuation_index}.json";否则统一写入 self.logs_dir / "trajectory.json"
  7. try 中以写模式打开目标文件,调用 format_trajectory_json(trajectory.to_json_dict()) 生成格式化 JSON 字符串并写入;成功后记录 debug 日志说明写入位置。
  8. 若写文件或序列化过程中抛出任意异常,则在 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 Noneself._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 并决定文件名/续段引用。
寄存器交互

子阶段