运行起点
Run Onset 这个 Stage 的存在,是为了把“已经准备好的环境”真正变成“一次新的、可追踪的运行”。前一阶段只把外部条件搭好;这里要解决的是:别让上一次运行的残留污染这一次,并且把当前终端、工具说明、技能说明整理成 LLM(大语言模型)第一轮就能用的起点。它处在进入主循环前的最后一道门。
- 先把“这次运行”从“上次运行”里切出来。
Terminus2._reset_per_run_state()(清掉本次 run 专属缓存)做的不是杂务,而是隔离。Agent 实例会被重复用,所以轨迹、计数、待完成引用、subagent(被主 Agent 调用的小 Agent)统计这些东西都要先清。否则这一轮会带着上一轮的脚印出发,后面的判断、记录、收尾都会乱。 - 再准备一个新的 Chat。
这里的 Chat(LLM 调用包装器)代表“这次 run 的对话入口”。为什么要新建?因为这一轮要有独立的推理上下文和消息流,而不是继续接着上一轮说。这样主循环里的每次模型调用,都会明确属于当前 session。
- 读一眼终端当前长什么样。
session.get_incremental_output()会抓当前终端屏幕。终端对 Agent 来说就是现场;如果第一轮看不到现场,模型只能盲答。这个 Stage 先把现场快照拿到,等于开工前先抬头看屏幕。 - 把“任务说明”补成“可行动的说明”。
这里会把原始 instruction 再拼上两类材料:
- MCP servers(外部工具/服务接入点)的说明:告诉模型现在有哪些可用能力。
_build_skills_section()(动态生成技能说明):把系统支持的操作方式整理给模型看。 这一步的意义是防止 LLM 只知道目标,不知道手里有什么工具、该怎么用。
- 生成第一条真正的提示词,并记进轨迹。
模板会把 instruction 和 terminal state 拼成初始 prompt。然后这条 prompt 会被写成 trajectory(运行轨迹)的第一步,来源记为
user。为什么要这么做?因为后面所有分析、调试、落盘都需要一个统一的“本次 run 是怎么开始的”记录,不能只存在内存里。 - 然后才进入主循环。
也就是说,主循环不是从“空白”开始,而是从一个已经清干净、看过现场、带着工具说明、并且第一步已入账的状态开始。外层再用
try/finally包住,是为了保证后面即使中途出错,收尾逻辑也还能跑。
- 清:
reg-trajectory-steps— run 一开始由_reset_per_run_state()清空,防止上一轮的轨迹混进这一轮 - 写:
reg-trajectory-steps— 初始 prompt 生成后立刻写入,作为这次 trial(试跑)的第 1 条正式记录 - 读:
reg-trajectory-steps— 本 Stage 不依赖旧值做决策,而是把它当作本轮的“空白账本”重新开张 - 触发下游:
stage-4 Iteration Loop ★ Core ★— 初始 prompt 入轨迹后,进入真正的按轮迭代
上一阶段给的是“已经可用的环境”和 session(终端会话)。这个 Stage 产出的是“本轮干净状态 + 初始现场 + 首条 prompt + 首条轨迹记录”。下一阶段据此开始循环:读轨迹、调 LLM、发动作、再把结果继续追加进去。
Terminus2._reset_per_run_stateterminus_2.py:1563–1578 ↗
每次 run 开始前的运行态清零器
stage 上下文: 该函数位于 stage-3「Run Onset」,负责在
Terminus2.run()进入主循环前,把仅应属于本次执行的累积状态全部恢复到初始值。触发条件是一次新的run()开始。作为本 stage 的起点,它先完成状态隔离,随后兄弟流程才会新建Chat、抓取终端初始画面并写入首条用户轨迹。
这段代码在干什么
函数不接收除 self 外的参数,核心作用是重置所有按单次 run() 计的内部字段,包括轨迹、请求耗时、轮次计数、摘要计数、子代理统计、待处理 completion / handoff 状态以及 marker 容器。它唯一读取的既有状态是 self._user_provided_session_id,用来决定本次运行沿用外部给定 session id,还是生成新的 UUID。函数返回 None,真正产出是对多个 self._... 字段的覆盖写入,这些字段随后被 stage-4 到 stage-6 持续消费。
接口 · 参数 / IO
(self) -> None
- 参数:
self:Terminus2— 当前代理实例;其跨次运行复用的内部状态在此被重置 - 读状态:
self._user_provided_session_id - 返回: 返回 None;实际结果是将本次
run()的各项累积寄存状态重置到干净起点,并确定self._session_id。 - 副作用: 写入 self._trajectory_steps; 写入 self._api_request_times; 写入 self._n_episodes; 写入 self._summarization_count; 写入 self._subagent_metrics; 写入 self._subagent_rollout_details; 写入 self._pending_completion; 写入 self._pending_subagent_refs; 写入 self._pending_handoff_prompt; 写入 self._timestamped_markers; 写入 self._session_id
执行流
- 先将面向本次运行的线性累积容器清空:
self._trajectory_steps = []、self._api_request_times = []、self._subagent_rollout_details = []、self._timestamped_markers = [],为后续轨迹记录、API 耗时统计、子代理 rollout 明细和 asciinema marker 采集提供空起点。 - 再把计数类状态归零:
self._n_episodes = 0与self._summarization_count = 0,使后续迭代计数和摘要调用计数从本轮重新累计。 - 随后重建子代理统计对象
self._subagent_metrics = SubagentMetrics(),而不是保留旧对象内容,确保子代理 token/cost 统计按本次run()独立核算。 - 接着清除跨迭代挂起状态:
self._pending_completion = False、self._pending_subagent_refs = None、self._pending_handoff_prompt = None,消除上一次运行遗留的二阶段 completion 确认、摘要引用和 handoff prompt。 - 最后确定本次运行的会话标识:若
self._user_provided_session_id为真值则直接沿用,否则通过str(uuid.uuid4())生成新的self._session_id。
源码
def _reset_per_run_state(self) -> None:
"""Reset all per-run state. The same Terminus2 instance is reused
across multiple `run()` invocations in multi-step trials, so any
accumulator that should be scoped to a single step must be reset here.
"""
self._trajectory_steps = []
self._api_request_times = []
self._n_episodes = 0
self._summarization_count = 0
self._subagent_metrics = SubagentMetrics()
self._subagent_rollout_details = []
self._pending_completion = False
self._pending_subagent_refs = None
self._pending_handoff_prompt = None
self._timestamped_markers = []
self._session_id = self._user_provided_session_id or str(uuid.uuid4())
Non-obvious 设计决策
- 该函数集中重置“per-run state”,而不是在各使用点零散清理;这是源码注释明确说明的取舍:同一个
Terminus2实例会跨多个run()复用,因此若不在入口统一清扫,上一轮 trial step 的累积值会泄漏到下一轮。 - 对子代理统计采用
SubagentMetrics()新建实例,而不是仅把若干字段手动置零;这避免旧对象内部残留不可见状态,也把“本轮统计起点”表达得更明确。 - 挂起态使用显式布尔/
None复位:_pending_completion设为False,而_pending_subagent_refs、_pending_handoff_prompt设为None。这种区分保留了不同状态机的语义边界:前者是确认门闩,后两者是“当前无待消费载荷”。 _session_id的选择采用“优先外部提供,否则生成 UUID”的策略,说明代码既支持调用方强制绑定会话标识,也支持默认获得每次运行唯一的 session。若始终生成新值,将破坏外部显式指定;若始终复用旧值,则不同 run 无法可靠分隔。
上下游关系
- 调用方: Terminus2.run
- 核心被调用: SubagentMetrics; uuid.uuid4; str
- 配置/状态来源: self._user_provided_session_id
- 结果去向: stage-3 后续的新建 Chat 流程读取本次干净起点; stage-4.1 递增并消费
self._n_episodes; stage-4.3 /_track_api_request_time继续向self._api_request_times追加; stage-4.5 / 4.6 / 4.8 / 4.9 继续向self._trajectory_steps写入; side-S1 /_summarize继续更新self._summarization_count、self._pending_subagent_refs、self._pending_handoff_prompt; stage-4.9 与 stage-5 使用这些重置后的累计字段做落盘与 metadata 汇总; stage-6 通过self._timestamped_markers输出 asciinema markers
reg-trajectory-steps— 新 run 前清空轨迹记录reg-api-request-times— 清空本轮 LLM 请求耗时reg-n-episodes— 轮次计数归零后续再累加reg-summarization-count— 摘要调用计数重新开始reg-subagent-metrics— 子代理 token 成本统计重建reg-pending-completion— 清除完成确认门闩reg-pending-subagent-refs— 清除待写入子代理引用reg-pending-handoff-prompt— 清除待消费 handoff 提示reg-asciinema-markers— 清空时间戳 marker 缓冲
Terminus2.runterminus_2.py:1586–1625 ↗
run 入口的初始提示装配段
stage 上下文: 这段代码位于
Terminus2.run的 pre-loop 开头,在进入主迭代前完成本次运行的局部初始化与首条提示构造。它紧接兄弟函数_reset_per_run_state之后执行:前者负责清空/轮换按次运行状态,这里则建立新的Chat、抓取终端快照,并把格式化后的初始提示落为轨迹首步。触发前提是run()已拿到instruction、environment与context,且self._session必须已设置。
这段代码在干什么
该片段先调用 _reset_per_run_state() 重置按次运行状态,再新建 Chat(self._llm, interleaved_thinking=self._interleaved_thinking) 并保存传入的 context。随后它要求 self._session 非空,读取当前终端增量输出并经 _limit_output_length(...) 截断;以原始 instruction 为起点,按需拼接 MCP server 描述和 _build_skills_section(environment) 生成的技能段,最后通过 self._prompt_template.format(...) 形成 initial_prompt。该提示被追加为一条新的 Step,其中显式写入 step_id=1、UTC ISO 时间戳、source="user" 和消息正文。
接口 · 参数 / IO
(self, instruction: str, environment: ToolEnvironment, context: RunContext) -> ?
- 参数:
instruction:str— 本次运行的原始用户指令,作为augmented_instruction的起始内容;environment:ToolEnvironment— 传给_build_skills_section(environment),用于构造可用技能说明;context:RunContext— 保存到self._context的运行上下文 - 读状态:
self._llm,self._interleaved_thinking,self._session,self.mcp_servers,self._prompt_template - 返回: 此源码片段内未出现
return语句;可见产出是对运行期状态的初始化、提示字符串的构造,以及轨迹列表的追加写入。 - 副作用: 调用
self._reset_per_run_state()重置按次运行状态; 以新实例覆盖self._chat; 将传入的context写入self._context; 若self._session is None则抛出RuntimeError("Session is not set"); 异步读取self._session.get_incremental_output()获取终端输出; 向self._trajectory_steps追加一条新的Step记录
执行流
- 先执行
self._reset_per_run_state(),随后创建新的Chat(self._llm, interleaved_thinking=self._interleaved_thinking)覆盖到self._chat,并把入参context保存到self._context。 - 检查
self._session:若为None,立即抛出RuntimeError("Session is not set"),本段后续逻辑不再继续。 - 通过
await self._session.get_incremental_output()读取当前终端增量输出,再交给self._limit_output_length(...)生成用于首条提示的terminal_state。 - 令
augmented_instruction = instruction作为起点;若self.mcp_servers为真,则构造mcp_info文本,并按s.transport分支:stdio时写入s.name、s.command与' '.join(s.args),否则写入s.name、s.transport与s.url;之后把该段拼接到原始指令后。 - 调用
await self._build_skills_section(environment)获取skills_section;仅当该结果为真值时,才继续拼接到augmented_instruction。 - 以
self._prompt_template.format(instruction=augmented_instruction, terminal_state=terminal_state)生成initial_prompt。 - 向
self._trajectory_steps追加一条Step:其中step_id=1,timestamp为datetime.now(timezone.utc).isoformat(),source="user",message=initial_prompt。
源码
self._reset_per_run_state()
self._chat = Chat(self._llm, interleaved_thinking=self._interleaved_thinking)
self._context = context
if self._session is None:
raise RuntimeError("Session is not set")
# Get the terminal state for the initial prompt
terminal_state = self._limit_output_length(
await self._session.get_incremental_output()
)
augmented_instruction = instruction
if self.mcp_servers:
mcp_info = "\n\nMCP Servers:\nThe following MCP servers are available for this task.\n"
for s in self.mcp_servers:
if s.transport == "stdio":
args_str = " ".join(s.args)
mcp_info += f"- {s.name}: stdio transport, command: {s.command} {args_str}\n"
else:
mcp_info += f"- {s.name}: {s.transport} transport, url: {s.url}\n"
augmented_instruction = instruction + mcp_info
skills_section = await self._build_skills_section(environment)
if skills_section:
augmented_instruction += skills_section
initial_prompt = self._prompt_template.format(
instruction=augmented_instruction,
terminal_state=terminal_state,
)
self._trajectory_steps.append(
Step(
step_id=1,
timestamp=datetime.now(timezone.utc).isoformat(),
source="user",
message=initial_prompt,
)
)
Non-obvious 设计决策
- 代码对
self._session做了显式None检查,并在缺失时直接抛出异常;这是源码中可见的前置条件收紧,而不是依赖后续异步调用在更深层位置失败。 - MCP server 描述采用
transport分支格式化:stdio分支输出命令与参数,其它分支输出传输类型与 URL;该取舍直接体现在if s.transport == "stdio"的字符串拼接路径中。 augmented_instruction明确先等于原始instruction,再按条件追加 MCP 信息与技能段;这使可选扩展不会替代原始指令内容。- 技能段采用 truthy gate(
if skills_section:)控制,说明_build_skills_section(environment)的结果允许为空/假值,而空结果不会被强行写入提示文本。
上下游关系
- 调用方: Terminus2.run
- 核心被调用: Terminus2._reset_per_run_state; Chat; TmuxSession.get_incremental_output; Terminus2._limit_output_length; Terminus2._build_skills_section
- 配置/状态来源:
self._llm:用于构造Chat;self._interleaved_thinking:作为Chat初始化参数;self.mcp_servers:决定是否追加 MCP 描述及其具体内容;self._prompt_template:用于格式化initial_prompt - 结果去向:
self._chat:被新的Chat(...)实例覆盖;self._context:保存传入的context;self._trajectory_steps:追加首条Step记录;initial_prompt局部变量:作为新建Step.message的值 - 同类 sibling: 与同 stage 的
_reset_per_run_state相比,前者负责清空按次运行寄存状态;本段在其后基于干净状态创建Chat、读取终端快照并写入首条轨迹。
reg-trajectory-steps— 追加 step_id=1 的首条用户轨迹
Terminus2._build_skills_sectionterminus_2.py:419–470 ↗
构造初始提示的技能清单段
stage 上下文: 该函数位于 stage-3 的提示构造阶段,在
Terminus2.run()完成单次运行状态重置、采集初始终端输出之后被调用。它负责把远端环境中可用的 Agent Skills 转换成可插入提示词的结构化片段,与兄弟逻辑中的 MCP server 描述一起扩充原始instruction。相较于_reset_per_run_state这类状态初始化函数,它不触碰运行期寄存器,只提供一段可选的提示文本。
这段代码在干什么
函数接收一个 BaseEnvironment,基于 self.skills_dir 在远端环境中发现技能文件 SKILL.md,并从文件 frontmatter 中抽取 name 与 description。若技能目录未配置、目录不存在、扫描无结果、文件不可读或 frontmatter 非法,则直接返回 None;只有存在至少一个有效技能时,才返回一个 <available_skills> XML 字符串。函数本身不修改任何 self._... 状态,唯一外部作用是通过 environment.exec(...) 和 environment.is_dir(...) 访问远端文件系统。
接口 · 参数 / IO
(self, environment: BaseEnvironment) -> str | None
- 参数:
self:Terminus2— 提供技能目录配置self.skills_dir与 frontmatter 解析器self._parse_skill_frontmatter;environment:BaseEnvironment— 远端环境抽象,用于检查目录存在性并执行find/cat命令读取技能文件 - 读状态:
self.skills_dir,self._parse_skill_frontmatter - 返回: 当未配置技能目录、目录不存在、扫描不到候选文件或没有任何 frontmatter 合法的技能时返回
None;否则返回带有available_skills/skill/name/description/location结构的 XML 文本,供run()拼接进初始提示。 - 副作用: 调用
environment.is_dir(self.skills_dir)读取远端目录状态; 调用environment.exec(...)在远端执行find与cat命令; 无实例字段写入
执行流
- 先检查
self.skills_dir是否已配置;若为空值,则立即返回None,跳过整段技能发现流程。 - 随后通过
await environment.is_dir(self.skills_dir)确认远端目录存在;若不是目录,则返回None。 - 在远端执行
find {skills_dir} -mindepth 2 -maxdepth 2 -name SKILL.md -type f | sort,把候选范围限定为skills_dir下第二层子目录中的SKILL.md;若命令失败、标准输出为空或只含空白,则返回None。 - 把
find输出按行拆成skill_md_paths,逐个对每个路径执行cat;若某个文件读取失败或无输出,则仅跳过该文件,不中断整个函数。 - 对成功读出的文件内容调用
self._parse_skill_frontmatter(...);只有返回非None的 frontmatter 才会被接受,并以(fm['name'], fm['description'], skill_md_path)形式加入entries。 - 若全部候选都被过滤掉、
entries为空,则返回None;否则构建根节点available_skills,为每项技能写入name、description和location三个子元素。 - 最后对 XML 树执行
indent(root, space=' ')美化缩进,并以tostring(..., encoding='unicode')序列化,在结果前额外拼接一个换行符后返回。
源码
async def _build_skills_section(self, environment: BaseEnvironment) -> str | None:
"""Discover Agent Skills in skills_dir and return an <available_skills> XML block.
Follows the Agent Skills spec: scans for subdirectories containing SKILL.md
inside the remote environment, parses YAML frontmatter for name/description,
and provides the absolute path so the model can ``cat`` the file to activate
a skill.
"""
if not self.skills_dir:
return None
if not await environment.is_dir(self.skills_dir):
return None
# List subdirectories containing SKILL.md in the remote environment
result = await environment.exec(
f"find {shlex.quote(self.skills_dir)} -mindepth 2 -maxdepth 2"
" -name SKILL.md -type f | sort",
timeout_sec=10,
)
if result.return_code != 0 or not result.stdout or not result.stdout.strip():
return None
skill_md_paths = result.stdout.strip().splitlines()
entries: list[tuple[str, str, str]] = [] # (name, description, location)
for skill_md_path in skill_md_paths:
cat_result = await environment.exec(
f"cat {shlex.quote(skill_md_path)}", timeout_sec=10
)
if cat_result.return_code != 0 or not cat_result.stdout:
continue
fm = self._parse_skill_frontmatter(cat_result.stdout)
if fm is None:
continue
entries.append((fm["name"], fm["description"], skill_md_path))
if not entries:
return None
from xml.etree.ElementTree import Element, SubElement, indent, tostring
root = Element("available_skills")
for name, description, location in entries:
skill = SubElement(root, "skill")
SubElement(skill, "name").text = name
SubElement(skill, "description").text = description
SubElement(skill, "location").text = location
indent(root, space=" ")
return "\n" + tostring(root, encoding="unicode")
Non-obvious 设计决策
- 函数采用“缺失即静默不暴露”的策略:
self.skills_dir未配置、目录不存在、扫描失败、没有有效 frontmatter 时都统一返回None,而不是抛错。这表明技能段是提示增强项而非运行前置条件,避免因技能目录问题阻塞run()的主流程。 - 技能发现完全委托给
environment执行远端find/cat,而不是在本地进程直接访问文件系统。这一取舍使发现结果与实际运行环境一致,特别适用于容器、远端 shell 或沙箱场景;代价是每个技能文件都要额外发起一次远端命令。 - 代码对单个坏文件采取局部容错:
cat失败或_parse_skill_frontmatter(...)返回None时只continue,不让一个损坏的SKILL.md污染整个技能列表。这样做提高了目录内容不整洁时的鲁棒性,但也意味着调用方拿不到被跳过文件的错误细节。 - 输出格式选择 XML 且显式包含
location,并在 docstring 中说明模型可据此cat文件来激活技能。这说明该返回值不是给程序二次解析,而是给后续提示词直接消费,强调“向模型声明可用技能及其绝对位置”的用途。
上下游关系
- 调用方: Terminus2.run(stage-3 中构造初始提示时调用)
- 核心被调用: environment.is_dir; environment.exec; self._parse_skill_frontmatter; xml.etree.ElementTree.Element; xml.etree.ElementTree.SubElement; xml.etree.ElementTree.indent; xml.etree.ElementTree.tostring; shlex.quote
- 配置/状态来源: self.skills_dir 提供远端技能根目录; self._parse_skill_frontmatter 定义可接受的技能元数据格式; environment 代表当前试验实际运行的远端文件系统/命令执行上下文; stage-3 的提示拼装约定决定其返回值会被附加到
instruction - 结果去向: 返回给 Terminus2.run; 被
run()追加到原始instruction中; 随后进入self._prompt_template.format(instruction=..., terminal_state=...); 最终以初始 user step 写入轨迹,供后续 LLM 对话消费 - 同类 sibling: 与
Terminus2._reset_per_run_state形成对照:后者重置单次运行寄存器,本函数不修改任何运行态,只生成可选提示片段。; 与Terminus2.run的 stage-3 片段直接衔接:run负责收集初始终端状态、拼接 MCP 描述与本函数返回的技能段,再形成首条 prompt。