Terminus 2 手册

运行起点

stage-3Run Onset3 个函数

Run Onset 这个 Stage 的存在,是为了把“已经准备好的环境”真正变成“一次新的、可追踪的运行”。前一阶段只把外部条件搭好;这里要解决的是:别让上一次运行的残留污染这一次,并且把当前终端、工具说明、技能说明整理成 LLM(大语言模型)第一轮就能用的起点。它处在进入主循环前的最后一道门。

  1. 先把“这次运行”从“上次运行”里切出来。

    Terminus2._reset_per_run_state()(清掉本次 run 专属缓存)做的不是杂务,而是隔离。Agent 实例会被重复用,所以轨迹、计数、待完成引用、subagent(被主 Agent 调用的小 Agent)统计这些东西都要先清。否则这一轮会带着上一轮的脚印出发,后面的判断、记录、收尾都会乱。

  2. 再准备一个新的 Chat。

    这里的 Chat(LLM 调用包装器)代表“这次 run 的对话入口”。为什么要新建?因为这一轮要有独立的推理上下文和消息流,而不是继续接着上一轮说。这样主循环里的每次模型调用,都会明确属于当前 session。

  3. 读一眼终端当前长什么样。

    session.get_incremental_output() 会抓当前终端屏幕。终端对 Agent 来说就是现场;如果第一轮看不到现场,模型只能盲答。这个 Stage 先把现场快照拿到,等于开工前先抬头看屏幕。

  4. 把“任务说明”补成“可行动的说明”。

    这里会把原始 instruction 再拼上两类材料:

    • MCP servers(外部工具/服务接入点)的说明:告诉模型现在有哪些可用能力。
    • _build_skills_section()(动态生成技能说明):把系统支持的操作方式整理给模型看。 这一步的意义是防止 LLM 只知道目标,不知道手里有什么工具、该怎么用。
  5. 生成第一条真正的提示词,并记进轨迹。

    模板会把 instruction 和 terminal state 拼成初始 prompt。然后这条 prompt 会被写成 trajectory(运行轨迹)的第一步,来源记为 user。为什么要这么做?因为后面所有分析、调试、落盘都需要一个统一的“本次 run 是怎么开始的”记录,不能只存在内存里。

  6. 然后才进入主循环。

    也就是说,主循环不是从“空白”开始,而是从一个已经清干净、看过现场、带着工具说明、并且第一步已入账的状态开始。外层再用 try/finally 包住,是为了保证后面即使中途出错,收尾逻辑也还能跑。

上一阶段给的是“已经可用的环境”和 session(终端会话)。这个 Stage 产出的是“本轮干净状态 + 初始现场 + 首条 prompt + 首条轨迹记录”。下一阶段据此开始循环:读轨迹、调 LLM、发动作、再把结果继续追加进去。

函数细节3

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-4stage-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

执行流

  1. 先将面向本次运行的线性累积容器清空:self._trajectory_steps = []self._api_request_times = []self._subagent_rollout_details = []self._timestamped_markers = [],为后续轨迹记录、API 耗时统计、子代理 rollout 明细和 asciinema marker 采集提供空起点。
  2. 再把计数类状态归零:self._n_episodes = 0self._summarization_count = 0,使后续迭代计数和摘要调用计数从本轮重新累计。
  3. 随后重建子代理统计对象 self._subagent_metrics = SubagentMetrics(),而不是保留旧对象内容,确保子代理 token/cost 统计按本次 run() 独立核算。
  4. 接着清除跨迭代挂起状态:self._pending_completion = Falseself._pending_subagent_refs = Noneself._pending_handoff_prompt = None,消除上一次运行遗留的二阶段 completion 确认、摘要引用和 handoff prompt。
  5. 最后确定本次运行的会话标识:若 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_countself._pending_subagent_refsself._pending_handoff_prompt; stage-4.9stage-5 使用这些重置后的累计字段做落盘与 metadata 汇总; stage-6 通过 self._timestamped_markers 输出 asciinema markers
寄存器交互
Terminus2.runterminus_2.py:1586–1625 ↗

run 入口的初始提示装配段

stage 上下文: 这段代码位于 Terminus2.run 的 pre-loop 开头,在进入主迭代前完成本次运行的局部初始化与首条提示构造。它紧接兄弟函数 _reset_per_run_state 之后执行:前者负责清空/轮换按次运行状态,这里则建立新的 Chat、抓取终端快照,并把格式化后的初始提示落为轨迹首步。触发前提是 run() 已拿到 instructionenvironmentcontext,且 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 记录

执行流

  1. 先执行 self._reset_per_run_state(),随后创建新的 Chat(self._llm, interleaved_thinking=self._interleaved_thinking) 覆盖到 self._chat,并把入参 context 保存到 self._context
  2. 检查 self._session:若为 None,立即抛出 RuntimeError("Session is not set"),本段后续逻辑不再继续。
  3. 通过 await self._session.get_incremental_output() 读取当前终端增量输出,再交给 self._limit_output_length(...) 生成用于首条提示的 terminal_state
  4. augmented_instruction = instruction 作为起点;若 self.mcp_servers 为真,则构造 mcp_info 文本,并按 s.transport 分支:stdio 时写入 s.names.command' '.join(s.args),否则写入 s.names.transports.url;之后把该段拼接到原始指令后。
  5. 调用 await self._build_skills_section(environment) 获取 skills_section;仅当该结果为真值时,才继续拼接到 augmented_instruction
  6. self._prompt_template.format(instruction=augmented_instruction, terminal_state=terminal_state) 生成 initial_prompt
  7. self._trajectory_steps 追加一条 Step:其中 step_id=1timestampdatetime.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、读取终端快照并写入首条轨迹。
寄存器交互
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 中抽取 namedescription。若技能目录未配置、目录不存在、扫描无结果、文件不可读或 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(...) 在远端执行 findcat 命令; 无实例字段写入

执行流

  1. 先检查 self.skills_dir 是否已配置;若为空值,则立即返回 None,跳过整段技能发现流程。
  2. 随后通过 await environment.is_dir(self.skills_dir) 确认远端目录存在;若不是目录,则返回 None
  3. 在远端执行 find {skills_dir} -mindepth 2 -maxdepth 2 -name SKILL.md -type f | sort,把候选范围限定为 skills_dir 下第二层子目录中的 SKILL.md;若命令失败、标准输出为空或只含空白,则返回 None
  4. find 输出按行拆成 skill_md_paths,逐个对每个路径执行 cat;若某个文件读取失败或无输出,则仅跳过该文件,不中断整个函数。
  5. 对成功读出的文件内容调用 self._parse_skill_frontmatter(...);只有返回非 None 的 frontmatter 才会被接受,并以 (fm['name'], fm['description'], skill_md_path) 形式加入 entries
  6. 若全部候选都被过滤掉、entries 为空,则返回 None;否则构建根节点 available_skills,为每项技能写入 namedescriptionlocation 三个子元素。
  7. 最后对 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.runstage-3 片段直接衔接:run 负责收集初始终端状态、拼接 MCP 描述与本函数返回的技能段,再形成首条 prompt。