LLM 调用
LLM Query Stage 的存在,是为了把前面准备好的上下文真正送进 LLM(大语言模型),并尽量稳地拿回一条“还能继续往下走”的回复。它不只是一次普通请求,而是整个循环里的抗故障层:记日志、记成本、处理上下文太长、处理输出被截断、必要时降级兜底。它位于“预判是否要先总结”之后、“正式解析回复”之前,负责把一次脆弱的模型调用变成可继续运行的结果。
- 先给这次对话开好记录位。
_setup_episode_logging()(为本轮 episode 准备专属日志路径)会先把日志文件位置定下来。原因很简单:LLM 调用最容易出问题,也最贵,必须能按轮次回看。与此同时,这个 Stage 还会记下调用前的 token 和 cost 基线,方便后面算“这次到底花了多少”。 - 然后真正去问模型。
_handle_llm_interaction()(包一层 LLM 交互入口)会把请求交给_query_llm()(真正发起模型调用并处理异常)。这一层的职责不是“调用一下 API”这么简单,而是确保调用结果可用、可追踪、可恢复。成功时,它会留下耗时记录,产出一条给下游解析的原始回复。 - 如果上下文太长,就当场减负,而不是直接失败。
当出现
ContextLengthExceededError(上下文超出模型可接收长度)时,系统会先用_unwind_messages_to_free_tokens()(回卷部分消息,腾出 token 空间)给提示词瘦身。 如果还不够,就走三层兜底总结:- 先尝试完整
_summarize - 再退到一次性短总结
- 最后连 LLM 总结都不依赖,只用一个“无 LLM”版本的 handoff 提示,加上一段终端尾部信息 这样设计的原因是:上下文过长是长期运行 Agent 的常态,不应让整轮崩掉。哪怕信息压缩得粗一些,也要尽量保住“继续做事”的能力。
- 先尝试完整
- 如果模型输出被截断,就尽量抢救,而不是整条作废。
当出现
OutputLengthExceededError(输出太长被截断)时,会先试parser.salvage_truncated_response(让解析器抢救残缺回复)。这里主要是 XML parser(XML 解析器)有能力从半截结构里捞出可用部分。 如果抢救不了,系统会回给 LLM 一条“你刚才输出被截断了,请按规则重来”的提示,再递归重试。 这一步的意义在于:模型有时已经给出了大半有效答案,直接丢掉会浪费调用和上下文。 - 其他临时性失败,就重试,不把偶发错误当致命错误。
_query_llm()外面有@retry(自动重试装饰器,基于 tenacity)兜住其他异常,最多重试 3 次。因为网络抖动、供应商瞬时故障、偶发解析问题,都不值得让 Agent 立刻停机。 - 在异常路径里,顺手把“交接材料”补上。
如果是被动触发的长上下文处理,这个 Stage 也会写好 pending handoff prompt(待交接提示)。这样后面即使需要切历史、加一步用户消息、或者把长上下文拆开,系统也已经备好了可交接的摘要,不用等到更后面才发现“材料不够”。
- 写:
reg-api-request-times— 每次实际发起 LLM 调用并返回或进入相应 try 路径时追加耗时;用来记录模型响应时间,后面会进入元数据统计 - 写:
reg-pending-handoff-prompt— 当ContextLengthExceededError触发被动总结/降级总结时写入;目的是为后续历史切分或交接准备一份可直接使用的摘要提示 - 读:
reg-pending-handoff-prompt— 本 Stage 不直接消费,但会判断并补写这份“待交接材料”,确保后续有可用输入 - 触发下游:
stage-4.4 Response Parse— 只要拿到了可用的 LLM 原始回复(正常返回、截断抢救成功、或重试后成功),就进入解析阶段
上一阶段 stage-4.2 已经提前探测过“要不要先总结”,尽量避免把超长上下文直接送去模型。本 Stage 接手真正的调用,并在探测失手时做最后一道补救,产出的是“尽可能完整、可解析”的模型回复,以及必要时补齐的 handoff 摘要;下游 stage-4.4 会把这条原始回复拆成结构化动作。
Terminus2._setup_episode_loggingterminus_2.py:509–522 ↗
构造单次 episode 日志路径
stage 上下文: 该单元是一个很小的路径准备辅助函数,只负责把
logging_dir与episode组合成固定目录与文件名。它不读取任何self._*状态,也不直接写入日志内容;与同 stage 里后续更复杂的 LLM 交互逻辑相比,这里仅提供可复用的路径约定与目录创建。
这段代码在干什么
函数接收 logging_dir 与 episode,返回三个 Path | None 值。若 logging_dir is None,直接返回 (None, None, None);否则创建子目录 episode-{episode},并返回其下的 debug.json、prompt.txt、response.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)创建目录
执行流
- 首先检查
logging_dir is None;若成立,立即返回三个None,不进行任何目录操作。 - 若提供了
logging_dir,则构造子目录路径logging_dir / f"episode-{episode}"。 - 对该子目录调用
mkdir(parents=True, exist_ok=True),确保目录存在。 - 最后返回该子目录下的三个具体文件路径:
debug.json、prompt.txt、response.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_tokens、chat.total_output_tokens、chat.total_cache_tokens 与 chat.total_cost,把它们保存为本次交互前的基线值。完成基线采样后,代码以 chat、prompt、logging_paths、original_instruction 和 self._session 作为参数,异步调用 _handle_llm_interaction(...)。该调用返回 6 个值,并被解包到 commands、is_task_complete、feedback、analysis、plan、llm_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 个局部结果:
commands、is_task_complete、feedback、analysis、plan、llm_response,供_run_agent_loop后续区域使用。 - 副作用: 调用
_setup_episode_logging(...)与_handle_llm_interaction(...);其具体副作用取决于被调函数,本片段仅可直接证明发生了这两次调用。
总体结构
| Region | 行号 | 角色 | 终态 |
|---|---|---|---|
| 1 | 1296-1303 | 建立日志路径并冻结调用前计量基线 | 得到 logging_paths 与四个 *_before 局部变量,进入 LLM 交互调用区域 |
| 2 | 1304-1313 | 发起 LLM 交互并解包六项结果 | 得到 commands、is_task_complete、feedback、analysis、plan、llm_response 六个局部输出,交给后续区域 |
Region 1 · 建立日志路径并冻结调用前计量基线 (terminus_2.py:1296-1303)
代码先以 logging_dir 和 episode 调用 self._setup_episode_logging(...),把结果保存到 logging_paths。随后没有立刻进入模型调用,而是先读取 chat.total_input_tokens、chat.total_output_tokens、chat.total_cache_tokens 和 chat.total_cost,分别存入 tokens_before_input、tokens_before_output、tokens_before_cache、cost_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 个返回值。传入的实参依次是 chat、prompt、logging_paths、original_instruction 和 self._session,显示这些上文准备好的输入会被整体交给该辅助流程。await 表明这是一次异步边界;返回后本地得到 commands、is_task_complete、feedback、analysis、plan、llm_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_tokens、chat.total_output_tokens、chat.total_cache_tokens、chat.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的副作用不属于此行直接可证内容
执行流
- 从
self解析出实例方法_query_llm。 - 按当前位置已有的参数顺序,将
chat、prompt、logging_paths、original_instruction、session原样作为 5 个实参传入。 - 以
await等待_query_llm(...)的异步执行结果。 - 把得到的返回值绑定到局部变量
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 接收 chat、prompt、日志路径三元组、original_instruction 和 session,先在常规路径中可选落盘原始 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(...)、messages与reset_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_refs与self._pending_handoff_prompt。; 在输出超限且无法抢救时,向chat.messages追加两条消息(用户原 prompt 与助手截断响应),并单独调用chat.reset_response_chain()重置响应链状态。
总体结构
| Region | 行号 | 角色 | 终态 |
|---|---|---|---|
| 1 | 1002-1020 | 常规请求与成功日志落盘 | 成功时直接返回 LLMResponse;若 chat.chat(...) 抛错则进入后续异常分支。 |
| 2 | 1024-1034 | 上下文超限前置校验与历史裁剪 | 若禁止摘要则重新抛出;否则准备进入三级摘要回退构造。 |
| 3 | 1035-1079 | 三级摘要回退 prompt 构造 | 生成 summary_prompt 并可选重写 prompt_path,随后进入回退请求。 |
| 4 | 1080-1099 | 回退 prompt 的二次请求 | 返回回退 LLMResponse;若回退请求也失败,则返回合成的技术故障 LLMResponse。 |
| 5 | 1102-1127 | 输出超限后的截断结果抢救 | 抢救成功时直接返回字符串;失败则进入纠错重试构造。 |
| 6 | 1128-1172 | 输出超限纠错消息与递归重试 | 把失败交换写回会话历史后递归调用自身,返回递归结果。 |
| 7 | 1175-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 基线采样。; 本函数是其核心下游,后者本身不做异常加工。
reg-api-request-times— 每次成功chat.chat(...)后追加耗时毫秒值。reg-pending-subagent-refs— 完整摘要成功时暂存摘要子代理引用。reg-pending-handoff-prompt— 完整摘要成功时暂存 handoff prompt。reg-chat-messages— 输出超限纠错时追加 user/assistant 两条历史消息。
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(...)输出调试日志
执行流
- 先通过
self._llm.get_model_context_limit()取得模型上下文上限,并保存到局部变量context_limit。 - 进入
while len(chat.messages) > 1循环;每轮都调用self._count_total_tokens(chat)计算当前 token 占用,再以context_limit - current_tokens得到free_tokens。 - 若
free_tokens >= target_free_tokens,说明空闲额度已达标,立即break结束裁剪。 - 若仍不达标,则执行尾部裁剪:代码用
chat._messages = chat.messages[:-2]一次移除最近两条消息;内层if len(chat.messages) >= 2 ... else: break与外层while len(chat.messages) > 1条件重复,表现为额外的防御性检查。 - 循环结束后,无论是达标退出还是长度不足退出,都会调用
chat.reset_response_chain(),使响应链状态与新的消息历史重新对齐。 - 最后再次调用
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对象。
reg-chat-messages— 截断消息尾部并随后重置响应链
Terminus2._parse_skill_frontmatterterminus_2.py:402–417 ↗
技能文档前置元数据提取器
stage 上下文: 该单元是一个静态风格的纯解析辅助函数:输入仅为
content字符串,不读取任何self状态。与同 stage 中围绕 LLM 调用、回退与消息裁剪的兄弟函数不同,这段代码不参与会话控制,只负责把一段文本开头可能存在的 YAML frontmatter 规整成统一返回值。
这段代码在干什么
函数尝试从 content 开头提取一段 YAML frontmatter,并仅在其中同时存在 name 与 description 两个键时返回对应字典。若文本开头不符合 frontmatter 边界、YAML 解析失败,或解析结果不是包含这两个键的映射,则返回 None。它不读写对象状态,也没有外部持久化副作用。
接口 · 参数 / IO
(content: str) -> dict[str, str] | None
- 参数:
content:str— 待检查的文档全文;函数只从其起始位置寻找 frontmatter - 返回: 返回
{"name": fm["name"], "description": fm["description"]}或None。代码只检查这两个键是否存在,不在运行时验证其值是否真为字符串。
执行流
- 先导入
re与yaml,然后用re.match(r"^---\n(.*?)\n---", content, re.DOTALL)只在content开头尝试匹配 frontmatter;该模式要求起始处就是---\n,之后某处出现一个\n---作为结束,但不要求结束分隔符后必须跟换行或到达字符串末尾。 - 如果
match为假,说明文本起始不具备该 frontmatter 结构,函数立即返回None。 - 匹配成功后,对
match.group(1)提取出的中间内容调用yaml.safe_load(...);若抛出yaml.YAMLError,则把该 frontmatter 视为不可用并返回None。 - YAML 解析成功后,继续检查
fm是否为dict,且其中是否同时含有键"name"与"description";任一条件不满足都返回None。 - 只有在上述检查全部通过时,函数才构造并返回一个只含两项的字典:
{"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 必须位于顶部的约定,避免把正文中的---片段误识别为元数据块。 - 返回值刻意只暴露
name和description两个字段,即使 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、日志路径或异常回退链,只做局部文本解析。