Terminus 2 手册

LLM 调用

stage-4.3LLM Query6 个函数

LLM Query Stage 的存在,是为了把前面准备好的上下文真正送进 LLM(大语言模型),并尽量稳地拿回一条“还能继续往下走”的回复。它不只是一次普通请求,而是整个循环里的抗故障层:记日志、记成本、处理上下文太长、处理输出被截断、必要时降级兜底。它位于“预判是否要先总结”之后、“正式解析回复”之前,负责把一次脆弱的模型调用变成可继续运行的结果。

  1. 先给这次对话开好记录位。

    _setup_episode_logging()(为本轮 episode 准备专属日志路径)会先把日志文件位置定下来。原因很简单:LLM 调用最容易出问题,也最贵,必须能按轮次回看。与此同时,这个 Stage 还会记下调用前的 token 和 cost 基线,方便后面算“这次到底花了多少”。

  2. 然后真正去问模型。

    _handle_llm_interaction()(包一层 LLM 交互入口)会把请求交给 _query_llm()(真正发起模型调用并处理异常)。这一层的职责不是“调用一下 API”这么简单,而是确保调用结果可用、可追踪、可恢复。成功时,它会留下耗时记录,产出一条给下游解析的原始回复。

  3. 如果上下文太长,就当场减负,而不是直接失败。

    当出现 ContextLengthExceededError(上下文超出模型可接收长度)时,系统会先用 _unwind_messages_to_free_tokens()(回卷部分消息,腾出 token 空间)给提示词瘦身。 如果还不够,就走三层兜底总结:

    • 先尝试完整 _summarize
    • 再退到一次性短总结
    • 最后连 LLM 总结都不依赖,只用一个“无 LLM”版本的 handoff 提示,加上一段终端尾部信息 这样设计的原因是:上下文过长是长期运行 Agent 的常态,不应让整轮崩掉。哪怕信息压缩得粗一些,也要尽量保住“继续做事”的能力。
  4. 如果模型输出被截断,就尽量抢救,而不是整条作废。

    当出现 OutputLengthExceededError(输出太长被截断)时,会先试 parser.salvage_truncated_response(让解析器抢救残缺回复)。这里主要是 XML parser(XML 解析器)有能力从半截结构里捞出可用部分。 如果抢救不了,系统会回给 LLM 一条“你刚才输出被截断了,请按规则重来”的提示,再递归重试。 这一步的意义在于:模型有时已经给出了大半有效答案,直接丢掉会浪费调用和上下文。

  5. 其他临时性失败,就重试,不把偶发错误当致命错误。

    _query_llm() 外面有 @retry(自动重试装饰器,基于 tenacity)兜住其他异常,最多重试 3 次。因为网络抖动、供应商瞬时故障、偶发解析问题,都不值得让 Agent 立刻停机。

  6. 在异常路径里,顺手把“交接材料”补上。

    如果是被动触发的长上下文处理,这个 Stage 也会写好 pending handoff prompt(待交接提示)。这样后面即使需要切历史、加一步用户消息、或者把长上下文拆开,系统也已经备好了可交接的摘要,不用等到更后面才发现“材料不够”。

上一阶段 stage-4.2 已经提前探测过“要不要先总结”,尽量避免把超长上下文直接送去模型。本 Stage 接手真正的调用,并在探测失手时做最后一道补救,产出的是“尽可能完整、可解析”的模型回复,以及必要时补齐的 handoff 摘要;下游 stage-4.4 会把这条原始回复拆成结构化动作。

函数细节6

Terminus2._setup_episode_loggingterminus_2.py:509–522 ↗

构造单次 episode 日志路径

stage 上下文: 该单元是一个很小的路径准备辅助函数,只负责把 logging_direpisode 组合成固定目录与文件名。它不读取任何 self._* 状态,也不直接写入日志内容;与同 stage 里后续更复杂的 LLM 交互逻辑相比,这里仅提供可复用的路径约定与目录创建。

这段代码在干什么

函数接收 logging_direpisode,返回三个 Path | None 值。若 logging_dir is None,直接返回 (None, None, None);否则创建子目录 episode-{episode},并返回其下的 debug.jsonprompt.txtresponse.txt 三个路径对象。其唯一外部副作用是按需创建目录。

接口 · 参数 / IO

(self, logging_dir: Path | None, episode: int) -> tuple[Path | None, Path | None, Path | None]

  • 参数: logging_dir: Path | None — 日志根目录;为 None 时表示禁用该路径准备逻辑; episode: int — 用于拼接子目录名 episode-{episode}
  • 返回: 返回一个三元组:若 logging_dir is None 则为 (None, None, None);否则为 episode_logging_dir / "debug.json"episode_logging_dir / "prompt.txt"episode_logging_dir / "response.txt" 三个 Path 对象。
  • 副作用: 当 logging_dir 非空时,在文件系统上执行 episode_logging_dir.mkdir(parents=True, exist_ok=True) 创建目录

执行流

  1. 首先检查 logging_dir is None;若成立,立即返回三个 None,不进行任何目录操作。
  2. 若提供了 logging_dir,则构造子目录路径 logging_dir / f"episode-{episode}"
  3. 对该子目录调用 mkdir(parents=True, exist_ok=True),确保目录存在。
  4. 最后返回该子目录下的三个具体文件路径:debug.jsonprompt.txtresponse.txt

源码

    def _setup_episode_logging(
        self, logging_dir: Path | None, episode: int
    ) -> tuple[Path | None, Path | None, Path | None]:
        if logging_dir is None:
            return None, None, None

        episode_logging_dir = logging_dir / f"episode-{episode}"
        episode_logging_dir.mkdir(parents=True, exist_ok=True)

        return (
            episode_logging_dir / "debug.json",
            episode_logging_dir / "prompt.txt",
            episode_logging_dir / "response.txt",
        )

Non-obvious 设计决策

  • 代码显式把 None 作为禁用分支:通过 if logging_dir is None: return None, None, None,调用方可以用缺省三元组表示“没有日志路径”,而不是依赖异常或空字符串约定。
  • 目录创建使用 mkdir(parents=True, exist_ok=True);这是一个源码可见的容错选择,允许父目录链一并创建,且目录已存在时不报错。
  • 返回值是 Path 对象而不是字符串或已打开文件句柄;该函数只完成路径计算与目录准备,把后续文件写入时机留给调用方。
  • 三个返回路径被固定命名在 episode-{episode} 子目录下,形成稳定的按 episode 分组的命名结构。

上下游关系

  • 调用方: 未知的上层调用方:向其提供按 episode 划分的三个日志文件路径
  • 核心被调用: Path.mkdir; Path.__truediv__
  • 配置/状态来源: 形参 logging_dir; 形参 episode
  • 结果去向: 返回给调用方的三元组; debug.json 对应的 Path | None; prompt.txt 对应的 Path | None; response.txt 对应的 Path | None
Terminus2._run_agent_loopterminus_2.py:1296–1313 (2 regions) ↗

在一次 agent 循环中,为本轮 LLM 交互建立日志路径、冻结调用前计量基线,并发起实际的模型交互调用。

stage 上下文: 该单元位于 stage-4.3「LLM Query」。与前面已翻译的 _setup_episode_logging 相衔接,这里先取得本 episode 的日志路径对象,再在发起模型调用前读取 chat 上的累计 token / cache / cost 计数。随后代码以一次 await self._handle_llm_interaction(...) 获得 6 个局部结果,供后续区域继续处理。

这段代码在干什么

这两段代码先调用 _setup_episode_logging(logging_dir, episode) 取得本轮要传递下去的 logging_paths。接着立即读取 chat.total_input_tokenschat.total_output_tokenschat.total_cache_tokenschat.total_cost,把它们保存为本次交互前的基线值。完成基线采样后,代码以 chatpromptlogging_pathsoriginal_instructionself._session 作为参数,异步调用 _handle_llm_interaction(...)。该调用返回 6 个值,并被解包到 commandsis_task_completefeedbackanalysisplanllm_response 这 6 个局部变量中。此片段本身不显示 enclosing function 的最终返回,只显示了本轮中间产物的生成。

接口 · 参数 / IO

async def Terminus2._run_agent_loop(...) -> ?

  • 参数: chat: ? — 当前对话对象;其累计 token 与 cost 计数在这里被读取,并作为 LLM 交互实参传入。; prompt: ? — 本轮要发送给模型的提示内容;直接传给 _handle_llm_interaction。; logging_dir: ? — episode 日志路径配置来源;传给 _setup_episode_logging。; episode: ? — 当前轮次标识;用于生成本轮日志路径。; original_instruction: ? — 原始任务指令;透传给 _handle_llm_interaction
  • 读状态: self._session, chat.total_input_tokens, chat.total_output_tokens, chat.total_cache_tokens, chat.total_cost
  • 返回: 此片段自身不返回值;它产出 6 个局部结果:commandsis_task_completefeedbackanalysisplanllm_response,供 _run_agent_loop 后续区域使用。
  • 副作用: 调用 _setup_episode_logging(...)_handle_llm_interaction(...);其具体副作用取决于被调函数,本片段仅可直接证明发生了这两次调用。

总体结构

Region行号角色终态
11296-1303建立日志路径并冻结调用前计量基线得到 logging_paths 与四个 *_before 局部变量,进入 LLM 交互调用区域
21304-1313发起 LLM 交互并解包六项结果得到 commandsis_task_completefeedbackanalysisplanllm_response 六个局部输出,交给后续区域

Region 1 · 建立日志路径并冻结调用前计量基线 (terminus_2.py:1296-1303)

代码先以 logging_direpisode 调用 self._setup_episode_logging(...),把结果保存到 logging_paths。随后没有立刻进入模型调用,而是先读取 chat.total_input_tokenschat.total_output_tokenschat.total_cache_tokenschat.total_cost,分别存入 tokens_before_inputtokens_before_outputtokens_before_cachecost_before。这些值都是在本次 LLM 交互发生前采集的局部快照。完成这些准备后,控制流进入下一段异步交互调用。

⤵ 此 region 调用 Terminus2._setup_episode_logging — 先取得本 episode 的日志路径对象

            logging_paths = self._setup_episode_logging(logging_dir, episode)

            # Track token counts and cost before this step
            tokens_before_input = chat.total_input_tokens
            tokens_before_output = chat.total_output_tokens
            tokens_before_cache = chat.total_cache_tokens
            cost_before = chat.total_cost

Region 2 · 发起 LLM 交互并解包六项结果 (terminus_2.py:1304-1313)

这里以元组解包的形式接收 _handle_llm_interaction(...) 的 6 个返回值。传入的实参依次是 chatpromptlogging_pathsoriginal_instructionself._session,显示这些上文准备好的输入会被整体交给该辅助流程。await 表明这是一次异步边界;返回后本地得到 commandsis_task_completefeedbackanalysisplanllm_response 六项结果,供后续代码继续消费。

⤵ 此 region 调用 Terminus2._handle_llm_interaction — 统一执行本轮模型交互并返回六项结果

            (
                commands,
                is_task_complete,
                feedback,
                analysis,
                plan,
                llm_response,
            ) = await self._handle_llm_interaction(
                chat, prompt, logging_paths, original_instruction, self._session
            )

Non-obvious 设计决策(跨 region 聚合)

  • 代码在 await self._handle_llm_interaction(...) 之前先读取四个累计计量字段(chat.total_input_tokenschat.total_output_tokenschat.total_cache_tokenschat.total_cost),这是显式的“调用前基线”设计:后续若要计算本轮增量,必须先冻结 pre-call 状态。
  • 日志路径不是在 _handle_llm_interaction(...) 内部隐式获取,而是先由 _setup_episode_logging(logging_dir, episode) 生成 logging_paths,再作为显式参数传入;这使“日志落点”成为本轮交互的显式输入,而非隐藏依赖。
  • 该段把 _handle_llm_interaction(...) 的结果一次性解包为六个具名局部变量,而不是保留为未结构化对象;可见的取舍是让后续区域直接按语义字段消费本轮交互产物。

上下游关系

  • 调用方: Terminus2._run_agent_loop(当前多 region 片段所属函数)
  • 核心被调用: Terminus2._setup_episode_logging; Terminus2._handle_llm_interaction
  • 配置/状态来源: logging_dir; episode; chat.total_input_tokens; chat.total_output_tokens; chat.total_cache_tokens; chat.total_cost; self._session
  • 结果去向: 局部变量 logging_paths; 局部变量 tokens_before_input; 局部变量 tokens_before_output; 局部变量 tokens_before_cache; 局部变量 cost_before; 局部变量 commands; 局部变量 is_task_complete; 局部变量 feedback; 局部变量 analysis; 局部变量 plan; 局部变量 llm_response; _run_agent_loop` 后续区域
  • 同类 sibling: Terminus2._setup_episode_logging:该兄弟函数负责产生日志路径;本片段消费其返回值并把它继续传给 LLM 交互流程。
Terminus2._handle_llm_interactionterminus_2.py:1186–1188 ↗

LLM 查询的异步委托语句

stage 上下文: 该代码片段仅展示一次异步方法调用与结果绑定。本单元能直接确认的角色,是把当前作用域中的 5 个局部输入原样交给 self._query_llm(...),并等待其返回值。

这段代码在干什么

此语句通过 await self._query_llm(chat, prompt, logging_paths, original_instruction, session) 发起一次异步委托调用。它直接读取 self 上的方法 _query_llm 与 5 个局部变量,并把等待得到的结果绑定到局部变量 llm_response。从可见代码看,这一行本身不做参数加工、不处理异常,也不直接写入任何状态寄存器;若被调方法抛错,异常将继续向外传播。

接口 · 参数 / IO

(self, chat, prompt, logging_paths, original_instruction, session) -> llm_response

  • 参数: self: ? — 提供被解析并调用的实例方法 _query_llm; chat: ? — 作为第 1 个实参转交给 _query_llm; prompt: ? — 作为第 2 个实参转交给 _query_llm; logging_paths: ? — 作为第 3 个实参转交给 _query_llm; original_instruction: ? — 作为第 4 个实参转交给 _query_llm; session: ? — 作为第 5 个实参转交给 _query_llm
  • 读状态: self(用于解析实例方法 _query_llm
  • 返回: 本语句不直接 return;其直接产出是把 awaited 结果绑定到局部变量 llm_response
  • 副作用: 无可由该语句本身直接证明的副作用;被调 _query_llm 的副作用不属于此行直接可证内容

执行流

  1. self 解析出实例方法 _query_llm
  2. 按当前位置已有的参数顺序,将 chatpromptlogging_pathsoriginal_instructionsession 原样作为 5 个实参传入。
  3. await 等待 _query_llm(...) 的异步执行结果。
  4. 把得到的返回值绑定到局部变量 llm_response

源码

        llm_response = await self._query_llm(
            chat, prompt, logging_paths, original_instruction, session
        )

Non-obvious 设计决策

  • 可见代码呈现为纯委托点:本地既不改写参数,也不包裹返回值,说明该处选择把查询细节完整下沉到 _query_llm,而非在当前层插入额外转换逻辑。
  • 该处没有局部 try/except。因此若 _query_llm 失败,异常会直接穿过此 await 向上传播;这是一个明确的边界划分,而不是在此处做容错。

上下游关系

  • 调用方: Terminus2._handle_llm_interaction(当前翻译单元所在函数)
  • 核心被调用: self._query_llm
  • 配置/状态来源: 局部变量 chat; 局部变量 prompt; 局部变量 logging_paths; 局部变量 original_instruction; 局部变量 session
  • 结果去向: 局部变量 llm_response
Terminus2._query_llmterminus_2.py:1002–1176 (7 regions) ↗

执行一次主 LLM 请求,并在上下文超限、输出超限或其他异常时分流到对应恢复路径的查询核心。

stage 上下文: 本单元位于 stage-4.3 的末端,请求参数已由更外层准备完毕并传入。函数先尝试一次常规 chat.chat(...),然后只对两类已知异常实施专门恢复:上下文长度超限与输出长度超限;其余异常仅记录日志并继续抛出。函数还负责记录成功 chat.chat(...) 调用的耗时,以及按分支写入 prompt/response 调试文件。

这段代码在干什么

_query_llm 接收 chatprompt、日志路径三元组、original_instructionsession,先在常规路径中可选落盘原始 prompt,再发起一次带计时的 chat.chat(...) 请求。若抛出 ContextLengthExceededError,代码先确认允许摘要回退、要求 session 非空,并调用 _unwind_messages_to_free_tokens(...) 释放 token,然后按“完整摘要 → 短摘要单次 LLM 调用 → 仅屏幕内容”的三级策略构造替代 prompt,再以该 prompt 重试一次 chat.chat(...)。若抛出 OutputLengthExceededError,代码先尝试从 truncated_response 中抢救可接受的部分结果;抢救失败时,再把原 prompt 与截断输出追加进 chat.messages,重置响应链,并递归调用自身向模型发出纠错消息。函数的返回值是分支相关的:通常为 LLMResponse,但在截断输出成功抢救时会直接返回抢救出的文本字符串。

接口 · 参数 / IO

async def _query_llm(self, chat, prompt, logging_paths, original_instruction, session)

  • 参数: chat: ? — 主对话对象;提供 chat.chat(...)messagesreset_response_chain(),并在输出超限纠错分支中被原地修改。; prompt: ? — 本次主请求的输入文本;常规路径直接发送,输出超限纠错时也会被回填进 chat.messages。; logging_paths: ? — 由 (logging_path, prompt_path, response_path) 组成的三元组,控制日志文件写入目标。; original_instruction: ? — 任务原始指令;上下文超限时用于构造各级摘要回退 prompt。; session: ? — 终端会话对象;上下文超限恢复分支依赖它抓取当前屏幕内容。
  • 读状态: self._llm_call_kwargs, self._enable_summarize, self._parser, self._llm, self._api_request_times, self.logger
  • 返回: 分支相关:常规路径与上下文超限回退路径返回 LLMResponse;输出超限且 salvage_truncated_response(...) 成功时返回抢救出的字符串;未知异常分支不返回而是重新抛出。
  • 副作用: 可能写入 prompt_path.write_text(prompt),以及在上下文超限回退中改写为 prompt_path.write_text(summary_prompt)。; 可能写入 response_path.write_text(...),内容可能是正常响应、回退响应、抢救出的截断文本或纠错消息。; 在每次成功完成的 chat.chat(...) 调用后向 self._api_request_times 追加一次耗时毫秒值;若回退 chat.chat(...) 自身失败并改用合成 LLMResponse,则不会追加耗时。; 在完整摘要成功时写入 self._pending_subagent_refsself._pending_handoff_prompt。; 在输出超限且无法抢救时,向 chat.messages 追加两条消息(用户原 prompt 与助手截断响应),并单独调用 chat.reset_response_chain() 重置响应链状态。

总体结构

Region行号角色终态
11002-1020常规请求与成功日志落盘成功时直接返回 LLMResponse;若 chat.chat(...) 抛错则进入后续异常分支。
21024-1034上下文超限前置校验与历史裁剪若禁止摘要则重新抛出;否则准备进入三级摘要回退构造。
31035-1079三级摘要回退 prompt 构造生成 summary_prompt 并可选重写 prompt_path,随后进入回退请求。
41080-1099回退 prompt 的二次请求返回回退 LLMResponse;若回退请求也失败,则返回合成的技术故障 LLMResponse
51102-1127输出超限后的截断结果抢救抢救成功时直接返回字符串;失败则进入纠错重试构造。
61128-1172输出超限纠错消息与递归重试把失败交换写回会话历史后递归调用自身,返回递归结果。
71175-1176未知异常记录并继续抛出记录错误日志后 raise e

Region 1 · 常规请求与成功日志落盘 (terminus_2.py:1002-1020)

这段先把 logging_paths 解包为 logging_path, prompt_path, response_path,若存在 prompt_path 就把入参 prompt 原样写入文件。随后在 try 中对 chat.chat(prompt, logging_path=logging_path, **self._llm_call_kwargs) 计时;只有该调用成功返回时,才会把耗时换算成毫秒并追加到 self._api_request_times。若存在 response_path,代码把 llm_response.content 落盘,然后直接返回该 LLMResponse。若这里抛出异常,则由后续各异常分支接管。

        logging_path, prompt_path, response_path = logging_paths

        if prompt_path is not None:
            prompt_path.write_text(prompt)

        try:
            start_time = time.time()
            llm_response = await chat.chat(
                prompt,
                logging_path=logging_path,
                **self._llm_call_kwargs,
            )
            end_time = time.time()
            request_time_ms = (end_time - start_time) * 1000
            self._api_request_times.append(request_time_ms)

            if response_path is not None:
                response_path.write_text(llm_response.content)
            return llm_response

Region 2 · 上下文超限前置校验与历史裁剪 (terminus_2.py:1024-1034)

这一段对应 ContextLengthExceededError 的开头处理。代码首先检查 self._enable_summarize:若摘要功能关闭,仅记录 debug 日志并重新抛出,不尝试恢复。若允许恢复,则再强制要求 session 非空;缺少会话时会抛出 RuntimeError。通过这些前置检查后,函数调用 _unwind_messages_to_free_tokens(chat, target_free_tokens=4000),先对现有对话历史做回退裁剪,再进入后面的摘要 prompt 构造流程。

⤵ 此 region 调用 Terminus2._unwind_messages_to_free_tokens — 上下文超限后先回卷历史以释放 token

            if not self._enable_summarize:
                self.logger.debug("Context length exceeded and summarization is OFF.")
                raise

            self.logger.debug("Context length exceeded. Using fallback summarization.")

            if session is None:
                raise RuntimeError("Cannot handle context length error without session")

            self._unwind_messages_to_free_tokens(chat, target_free_tokens=4000)

Region 3 · 三级摘要回退 prompt 构造 (terminus_2.py:1035-1079)

这段把替代性 summary_prompt 的生成分成三层。第一层优先调用 await self._summarize(chat, original_instruction, session);若成功,不仅拿到 summary_prompt,还会把 subagent_trajectory_refs 写入 self._pending_subagent_refs,并把 handoff 文本写入 self._pending_handoff_prompt。若完整摘要失败,代码退到第二层:通过 await session.capture_pane(capture_entire=False) 读取当前屏幕,并在 current_screen[-1000:] if current_screen else "" 的约束下截取末尾内容,构造一个请求模型“Next steps (2-3 sentences)”的 short_prompt,再调用未计时的 self._llm.call(...) 生成短摘要。若第二层仍失败,则进入第三层 ultimate fallback:再次按同样的 [-1000:] if ... else "" 规则抓取屏幕尾部,直接把 original_instruction 与当前屏幕拼接成 summary_prompt,不再发起额外 LLM 调用。完成后,若 prompt_path 存在,会用这个 summary_prompt 覆盖先前已写入的原始 prompt 文件。

⤵ 此 region 调用 Terminus2._summarize — 完整摘要优先,成功时回填待消费寄存器

            summary_prompt = None
            # Fallback 1: Try full summary
            try:
                self.logger.debug("SUMMARIZATION: Attempting full summary")
                summary_prompt, subagent_trajectory_refs = await self._summarize(
                    chat, original_instruction, session
                )
                # Store subagent_refs to include in the trajectory
                self._pending_subagent_refs = subagent_trajectory_refs
                # Store handoff prompt to add as a user step
                self._pending_handoff_prompt = summary_prompt
                self.logger.debug("SUMMARIZATION: Full summary succeeded")
            except Exception as e:
                self.logger.debug(f"SUMMARIZATION: Full summary failed: {e}")

            # Fallback 2: Try short summary
            if summary_prompt is None:
                try:
                    self.logger.debug("SUMMARIZATION: Attempting short summary")
                    current_screen = await session.capture_pane(capture_entire=False)
                    limited_screen = current_screen[-1000:] if current_screen else ""

                    short_prompt = f"Briefly continue this task: {original_instruction}\n\nCurrent state: {limited_screen}\n\nNext steps (2-3 sentences):"

                    short_llm_response: LLMResponse = await self._llm.call(
                        prompt=short_prompt,
                        **self._llm_call_kwargs,
                    )
                    summary_prompt = f"{original_instruction}\n\nSummary: {short_llm_response.content}"
                    self.logger.debug("SUMMARIZATION: Short summary succeeded")
                except Exception as e:
                    self.logger.error(f"SUMMARIZATION: Short summary failed: {e}")

            # Fallback 3: Ultimate fallback (no LLM calls)
            if summary_prompt is None:
                self.logger.debug("SUMMARIZATION: Using ultimate fallback")
                current_screen = await session.capture_pane(capture_entire=False)
                limited_screen = current_screen[-1000:] if current_screen else ""
                summary_prompt = (
                    f"{original_instruction}\n\nCurrent state: {limited_screen}"
                )

            if prompt_path is not None:
                prompt_path.write_text(summary_prompt)

Region 4 · 回退 prompt 的二次请求 (terminus_2.py:1080-1099)

拿到 summary_prompt 后,这段再次调用 chat.chat(summary_prompt, logging_path=logging_path, **self._llm_call_kwargs),并像常规路径一样只在成功时记录耗时到 self._api_request_times。如果这次回退请求仍然失败,代码不会继续向外抛错,而是记录错误日志并构造一个固定文本的 LLMResponse(content="Technical difficulties. Please continue with the task.")。无论是真实响应还是该合成响应,只要存在 response_path 就写入其 content,随后返回该 LLMResponse

            try:
                start_time = time.time()
                llm_response = await chat.chat(
                    summary_prompt,
                    logging_path=logging_path,
                    **self._llm_call_kwargs,
                )
                end_time = time.time()
                request_time_ms = (end_time - start_time) * 1000
                self._api_request_times.append(request_time_ms)

            except Exception as e:
                self.logger.error(f"Even fallback chat failed: {e}")
                llm_response = LLMResponse(
                    content="Technical difficulties. Please continue with the task."
                )

            if response_path is not None:
                response_path.write_text(llm_response.content)
            return llm_response

Region 5 · 输出超限后的截断结果抢救 (terminus_2.py:1102-1127)

这段处理 OutputLengthExceededError。代码先从异常对象提取 truncated_response,若异常上没有该属性就使用占位字符串。随后它仅在 self._parser 具备 salvage_truncated_response 方法时尝试抢救,并接收 (salvaged_response, has_multiple_blocks) 两个返回值;其中 has_multiple_blocks 在可见代码中只被接收、不参与后续判断。若 salvaged_response 为真值,函数记录一条说明使用截断版本的 debug 日志,可选把该文本写入 response_path,然后直接返回这个抢救出的字符串,而不是 LLMResponse

            self.logger.debug(f"Output length exceeded: {e}")

            truncated_response = getattr(
                e, "truncated_response", "[TRUNCATED RESPONSE NOT AVAILABLE]"
            )

            # Try to salvage a valid response from the truncated output
            # Only available for XML parser
            salvaged_response = None
            has_multiple_blocks = False

            if hasattr(self._parser, "salvage_truncated_response"):
                salvaged_response, has_multiple_blocks = (
                    self._parser.salvage_truncated_response(truncated_response)
                )

            if salvaged_response:
                self.logger.debug(
                    "Output exceeded length but found valid response. "
                    "Using truncated version."
                )

                if response_path is not None:
                    response_path.write_text(salvaged_response)

                return salvaged_response

Region 6 · 输出超限纠错消息与递归重试 (terminus_2.py:1128-1172)

当无法从截断内容中抢救有效结果时,这段会先做一次尽力而为的解析:调用 self._parser.parse_response(truncated_response) 仅为了提取 parse_result.warning,解析失败只记 debug 日志并忽略,不阻断后续流程。然后代码读取 self._llm.get_model_output_limit() 生成面向模型的限制说明,拼出 error_msg,内容明确声明上一轮动作都未执行,并要求把请求拆成小于上限的块;若前面获得了解析 warning,就把 warning 追加到该消息末尾。接下来它向 chat.messages 依次追加两条消息:{"role": "user", "content": prompt}{"role": "assistant", "content": truncated_response},再单独调用 chat.reset_response_chain() 清理响应链状态。若存在 response_path,此处写入的是纠错消息 error_msg。最后函数以这个 error_msg 作为新 prompt 递归调用自身,并把递归结果原样返回。


            # If we get here, we couldn't salvage a valid response
            # Try to parse the truncated response to get warnings
            warnings_text = ""
            try:
                parse_result = self._parser.parse_response(truncated_response)
                if parse_result.warning:
                    warnings_text = (
                        f"\n\nParser warnings from your truncated response:\n"
                        f"{parse_result.warning}"
                    )
            except Exception as parse_error:
                self.logger.debug(f"Failed to parse truncated response: {parse_error}")

            # Get the actual output limit for the model
            output_limit = self._llm.get_model_output_limit()
            if output_limit is not None:
                limit_str = f"{output_limit} tokens"
            else:
                limit_str = "the maximum output length"

            error_msg = (
                "ERROR!! NONE of the actions you just requested were performed "
                f"because you exceeded {limit_str}. "
                f"Your outputs must be less than {limit_str}. Re-issue this request, "
                f"breaking it into chunks each of which is less than {limit_str}."
            )

            if warnings_text:
                error_msg += warnings_text

            chat.messages.append({"role": "user", "content": prompt})
            chat.messages.append({"role": "assistant", "content": truncated_response})
            chat.reset_response_chain()

            if response_path is not None:
                response_path.write_text(error_msg)

            return await self._query_llm(
                chat=chat,
                prompt=error_msg,
                logging_paths=logging_paths,
                original_instruction=original_instruction,
                session=session,
            )

Region 7 · 未知异常记录并继续抛出 (terminus_2.py:1175-1176)

这两行是兜底异常分支:对于未被前面专门处理的错误,函数只记录 self.logger.error(f"Unknown Error in LLM interaction: {e}"),随后执行 raise e。因此这里不是吞掉异常,而是把决定权交还给更上层调用者。

            self.logger.error(f"Unknown Error in LLM interaction: {e}")
            raise e

Non-obvious 设计决策(跨 region 聚合)

  • 上下文超限恢复采用分层降级,而不是单一路径:先尝试完整 _summarize(...),失败后退到一次未计时的 self._llm.call(...) 短摘要,再失败才退到完全不依赖 LLM 的屏幕尾部拼接。这种结构把最强恢复能力放在前面,同时保留在摘要子流程失效时的最低保真继续运行能力。
  • 函数只为成功完成的 chat.chat(...) 记录 API 耗时;短摘要分支使用的 self._llm.call(...) 不参与该计时,回退 chat.chat(...) 失败并改用合成 LLMResponse 时也不会追加耗时。这体现了 self._api_request_times 只追踪主查询链上成功 chat 请求的选择。
  • 输出超限时优先尝试直接抢救截断内容,而不是一律重试;说明代码把“部分可用结果”视为优于重新生成。只有抢救失败时,才把原始交换写回 chat.messages 并递归重试,以显式告诉模型此前因超限而未执行任何动作。
  • 对截断输出的二次解析是纯粹的 best-effort 行为:parse_response(truncated_response) 仅用于提取 warning 文本,解析失败只写 debug 日志并忽略,不会改变主恢复路径。这避免了恢复逻辑再次被解析器错误阻断。
  • 调试文件中的 prompt 不是固定代表原始输入:函数一开始可能先把 prompt 写入 prompt_path,但若进入上下文超限回退并成功构造 summary_prompt,同一路径会被再次写入替代 prompt。因此磁盘上的 prompt 文件反映的是“最终用于请求的 prompt”,而非调用入口实参的不可变快照。

上下游关系

  • 调用方: 直接等待其结果,接收 LLMResponse 或抢救出的字符串。
  • 核心被调用: 主路径与摘要回退路径的实际 LLM 对话请求。; 上下文超限后先压缩消息历史。; 首选的完整摘要恢复方案。; 短摘要与最终回退都读取当前屏幕尾部。; 短摘要回退时发起一次独立 LLM 调用。; 从截断输出中抢救可接受文本。; 仅为提取截断输出中的 warning。
  • 配置/状态来源: 两次 chat.chat(...) 与短摘要 self._llm.call(...) 都复用该调用参数。; 决定上下文超限时是否允许进入摘要恢复。; 决定是否支持截断抢救与 warning 提取。; 提供短摘要调用入口与模型输出上限。
  • 结果去向: 返回主查询结果、回退 LLMResponse,或截断抢救字符串。; 保存原始 prompt 或回退后的 summary_prompt。; 保存正常响应、回退响应、抢救文本或纠错消息。
  • 同类 sibling: 其返回的三元日志路径在本函数开头被解包并使用。; 更外层在调用链前后负责总体 token/cost 基线采样。; 本函数是其核心下游,后者本身不做异常加工。
寄存器交互
Terminus2._unwind_messages_to_free_tokensterminus_2.py:568–592 ↗

压缩对话历史以腾出上下文空间

stage 上下文: 该函数位于 stage-4.3 所属类的方法集合中,是一次局部聊天状态整理工具。它本身不发起 LLM 请求,而是围绕 chat 的消息历史做裁剪,并在结束时重置响应链、输出调试日志。与同 stage 中负责真正请求模型的兄弟函数相比,这里只处理上下文容量相关的前置整理。

这段代码在干什么

函数接收 chat 与目标空闲 token 数 target_free_tokens,读取 self._llm 提供的上下文上限,并反复用 self._count_total_tokens(chat) 估算当前占用。只要空闲额度不足且 chat.messages 仍多于 1 条,它就把内部消息列表缩短为 chat.messages[:-2],即从尾部一次去掉两条消息。循环结束后,函数重置 chat 的响应链,重新计算一次剩余 token 预算并写出调试日志;返回值为 None,真正产出是对 chat 内部状态的修改。

接口 · 参数 / IO

(self, chat: Chat, target_free_tokens: int = 4000) -> None

  • 参数: chat: Chat — 被裁剪消息历史并重置响应链的会话对象; target_free_tokens: int — 期望至少保留的空闲 token 数,默认 4000
  • 读状态: self._llm, self.logger, chat.messages
  • 返回: 返回 None;实际结果是缩短 chat 的内部消息列表并重置其响应链。
  • 副作用: 调用 chat._messages = chat.messages[:-2] 直接截断消息历史; 调用 chat.reset_response_chain() 重置会话响应链状态; 调用 self.logger.debug(...) 输出调试日志

执行流

  1. 先通过 self._llm.get_model_context_limit() 取得模型上下文上限,并保存到局部变量 context_limit
  2. 进入 while len(chat.messages) > 1 循环;每轮都调用 self._count_total_tokens(chat) 计算当前 token 占用,再以 context_limit - current_tokens 得到 free_tokens
  3. free_tokens >= target_free_tokens,说明空闲额度已达标,立即 break 结束裁剪。
  4. 若仍不达标,则执行尾部裁剪:代码用 chat._messages = chat.messages[:-2] 一次移除最近两条消息;内层 if len(chat.messages) >= 2 ... else: break 与外层 while len(chat.messages) > 1 条件重复,表现为额外的防御性检查。
  5. 循环结束后,无论是达标退出还是长度不足退出,都会调用 chat.reset_response_chain(),使响应链状态与新的消息历史重新对齐。
  6. 最后再次调用 self._count_total_tokens(chat) 重新估算剩余空间,计算新的 free_tokens,并通过 self.logger.debug(...) 记录剩余消息数与“approximately”标记的空闲 token 数。

源码

    def _unwind_messages_to_free_tokens(
        self, chat: Chat, target_free_tokens: int = 4000
    ) -> None:
        """Remove recent messages until we have enough free tokens."""
        context_limit = self._llm.get_model_context_limit()

        while len(chat.messages) > 1:  # Keep at least the first message
            current_tokens = self._count_total_tokens(chat)
            free_tokens = context_limit - current_tokens

            if free_tokens >= target_free_tokens:
                break

            # Remove the most recent pair of messages (user + assistant)
            if len(chat.messages) >= 2:
                chat._messages = chat.messages[:-2]
            else:
                break

        chat.reset_response_chain()
        free_tokens = context_limit - self._count_total_tokens(chat)
        self.logger.debug(
            f"Unwound messages. Remaining messages: {len(chat.messages)}, "
            f"Free tokens: approximately {free_tokens}"
        )

Non-obvious 设计决策

  • 循环条件写成 len(chat.messages) > 1,对应的可观察取舍是始终保留第一条消息,不会把消息列表裁到空。
  • 裁剪单位固定为 chat.messages[:-2],即每次从尾部去掉两条消息,而不是一条条删;这使消息历史按成对粒度回退。
  • 每轮都重新调用 self._count_total_tokens(chat),表明是否继续裁剪不是按消息条数估计,而是按实时 token 占用重新判断。
  • 内层 if len(chat.messages) >= 2 在外层 while len(chat.messages) > 1 之下基本属于冗余的防御性检查;源码显式保留了这层检查而非完全依赖外层条件。
  • 日志前又重新计算一次 free_tokens,且日志文案使用 approximately,说明这里记录的是一次结束后的近似预算,而不是沿用循环中的旧值。

上下游关系

  • 调用方: Terminus2._query_llm
  • 核心被调用: self._llm.get_model_context_limit; self._count_total_tokens; chat.reset_response_chain; self.logger.debug
  • 配置/状态来源: self._llm; chat.messages; target_free_tokens; self.logger
  • 结果去向: chat._messages 被缩短后的会话历史; chat.reset_response_chain() 之后的 chat 内部响应链状态; self.logger.debug(...) 输出的调试日志
  • 同类 sibling: Terminus2._query_llm:该兄弟函数负责发起请求;本函数只负责在局部对 chat 历史做裁剪与重置。; Terminus2._handle_llm_interaction:该兄弟函数仅转调用 _query_llm;相比之下,本函数直接修改 chat 对象。
寄存器交互
Terminus2._parse_skill_frontmatterterminus_2.py:402–417 ↗

技能文档前置元数据提取器

stage 上下文: 该单元是一个静态风格的纯解析辅助函数:输入仅为 content 字符串,不读取任何 self 状态。与同 stage 中围绕 LLM 调用、回退与消息裁剪的兄弟函数不同,这段代码不参与会话控制,只负责把一段文本开头可能存在的 YAML frontmatter 规整成统一返回值。

这段代码在干什么

函数尝试从 content 开头提取一段 YAML frontmatter,并仅在其中同时存在 namedescription 两个键时返回对应字典。若文本开头不符合 frontmatter 边界、YAML 解析失败,或解析结果不是包含这两个键的映射,则返回 None。它不读写对象状态,也没有外部持久化副作用。

接口 · 参数 / IO

(content: str) -> dict[str, str] | None

  • 参数: content: str — 待检查的文档全文;函数只从其起始位置寻找 frontmatter
  • 返回: 返回 {"name": fm["name"], "description": fm["description"]}None。代码只检查这两个键是否存在,不在运行时验证其值是否真为字符串。

执行流

  1. 先导入 reyaml,然后用 re.match(r"^---\n(.*?)\n---", content, re.DOTALL) 只在 content 开头尝试匹配 frontmatter;该模式要求起始处就是 ---\n,之后某处出现一个 \n--- 作为结束,但不要求结束分隔符后必须跟换行或到达字符串末尾。
  2. 如果 match 为假,说明文本起始不具备该 frontmatter 结构,函数立即返回 None
  3. 匹配成功后,对 match.group(1) 提取出的中间内容调用 yaml.safe_load(...);若抛出 yaml.YAMLError,则把该 frontmatter 视为不可用并返回 None
  4. YAML 解析成功后,继续检查 fm 是否为 dict,且其中是否同时含有键 "name""description";任一条件不满足都返回 None
  5. 只有在上述检查全部通过时,函数才构造并返回一个只含两项的字典:{"name": fm["name"], "description": fm["description"]}

源码

    def _parse_skill_frontmatter(content: str) -> dict[str, str] | None:
        """Parse YAML frontmatter from SKILL.md content, returning name and description."""
        import re

        import yaml

        match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
        if not match:
            return None
        try:
            fm = yaml.safe_load(match.group(1))
        except yaml.YAMLError:
            return None
        if not isinstance(fm, dict) or "name" not in fm or "description" not in fm:
            return None
        return {"name": fm["name"], "description": fm["description"]}

Non-obvious 设计决策

  • 函数采用“失败即缺失”的封闭式容错:未匹配到 frontmatter、YAML 语法错误、或字段不完整时都统一返回 None,而不是抛出异常;这样调用方可以把无效 frontmatter 与不存在 frontmatter 一并处理。
  • 正则使用 re.match 而非搜索全文,明确把解析范围限制在文档开头;这体现了 frontmatter 必须位于顶部的约定,避免把正文中的 --- 片段误识别为元数据块。
  • 返回值刻意只暴露 namedescription 两个字段,即使 YAML 中还有其他键也被忽略;函数目标是提取最小必需元数据,而非回传完整 frontmatter。
  • 类型注解写为 dict[str, str] | None,但运行时校验只做到“键存在”,并未验证 fm["name"]fm["description"] 的实际类型;这是一种偏宽松的输入接受策略。

上下游关系

  • 调用方: 未在当前代码片段中出现直接调用者;应由上层文本组装/解析流程调用
  • 核心被调用: re.match; yaml.safe_load
  • 配置/状态来源: 参数 content; 正则字面量 r"^---\n(.*?)\n---"; 正则标志 re.DOTALL; 键名常量 "name"; 键名常量 "description"
  • 结果去向: 返回给调用方的两键字典; 返回给调用方的 None(表示 frontmatter 不可用或缺失)
  • 同类 sibling: 与 _query_llm_unwind_messages_to_free_tokens 等同 stage 兄弟不同,本函数不操作 chat、日志路径或异常回退链,只做局部文本解析。