本系列基于 mini-SWE-agent 源码学习整理;源码版本固定为 Asuhe404/mini-swe-agent@388da74,便于长期核对实现。

配套练习:下载 07_configuration_factories_interactive.py

系列导航

返回路线图 · 上一篇:06. Model 适配与 action 翻译:把模型方言变成统一命令 · 下一篇:08. 测试驱动扩展:用可预测替身保护 Agent 三层契约

适合:零 Python 基础、已有 JavaScript 基础。
重点文件:

  • src/minisweagent/config/mini.yaml
  • src/minisweagent/config/__init__.py
  • src/minisweagent/run/mini.py
  • src/minisweagent/agents/interactive.py

本节目标:关闭源码后,能够从一条 mini 命令追到最终的 Model、Environment 和 InteractiveAgent 实例;能够解释后配置为什么覆盖前配置;能够说清 humanconfirmyolo 三种模式怎样改变同一个 Agent 核心循环。

1. 先用 JavaScript 建立心智模型

1.1 整体最像“默认对象 + 多层 override + 依赖注入”

先把 Python 放在一边。run/mini.py 的主体可以近似理解成下面这段 JavaScript:

const configLayers = configSpecs.map((spec) =>
  spec.includes("=")
    ? dottedKeyToObject(spec)
    : YAML.parse(resolveConfigFile(spec)),
);

configLayers.push({
  run: {
    task: task || UNSET,
  },
  agent: {
    agent_class: agentClass || UNSET,
    mode: yolo ? "yolo" : UNSET,
    cost_limit: costLimit ?? UNSET,
    confirm_exit: exitImmediately ? false : UNSET,
  },
  model: {
    model_class: modelClass || UNSET,
    model_name: modelName || UNSET,
  },
  environment: {
    environment_class: environmentClass || UNSET,
  },
});

const config = deepMergeLeftToRight(...configLayers);

const model = createModel(config.model);
const environment = createEnvironment(config.environment, "local");
const agent = createAgent(model, environment, config.agent, "interactive");

agent.run(config.run.task);

核心不是某一个语法,而是四步:

  1. YAML 提供完整默认对象。
  2. -c key=value 和 CLI options 提供局部覆盖。
  3. 深合并时,越靠后的层优先级越高。
  4. 三个工厂把三个配置段变成真实对象,再把 Model 和 Environment 注入 Agent。

1.2 UNSET 最像“不要覆盖”,不是 null

JavaScript 中常用 undefined 表示“调用者没有提供这个字段”,用 null 表示“调用者明确提供了空值”。本项目额外创建了一个唯一对象:

UNSET = object()

它的语义是:

这一层对该字段没有意见,合并时跳过它,保留较早配置的值。

对比:

含义合并结果
UNSET没提供,不要覆盖保留旧值
None明确给出 Python 空值通常覆盖旧值为 None
0明确关闭限制必须保留并覆盖旧值
False明确关闭布尔选项必须保留并覆盖旧值

因此 cost_limit 不能写成 cost_limit or UNSET0 是合法值,却是 falsy;源码改用:

cost_limit if cost_limit is not None else UNSET

这近似 JavaScript 的:

costLimit ?? UNSET

而不是:

costLimit || UNSET

1.3 三个工厂最像 registry + dynamic import + new

Agent 工厂的核心近似:

const AGENTS = {
  default: DefaultAgent,
  interactive: InteractiveAgent,
};

function createAgent(model, environment, config, defaultType) {
  const copied = structuredClone(config);
  const type = copied.agent_class ?? defaultType;
  delete copied.agent_class;
  const AgentClass = AGENTS[type] ?? dynamicImport(type);
  return new AgentClass(model, environment, copied);
}

Python 的 get_model()get_environment()get_agent() 都采用同一思路:

  • 常用短名走映射表。
  • 完整的 package.module.ClassName 走动态 import。
  • 选择 class 后用配置构造实例。
  • Agent 最后构造,因为它依赖前两个实例。

这就是依赖注入:InteractiveAgent 不负责创建模型和执行环境,它只接收已经装配好的对象。

1.4 InteractiveAgent 最像覆盖四个 hook 的 JS 子类

class InteractiveAgent extends DefaultAgent {
  addMessages(...messages) {
    print(messages);
    return super.addMessages(...messages);
  }

  query() {
    if (this.config.mode === "human") {
      return messageContainingUserCommand();
    }
    return super.query();
  }

  step() {
    try {
      return super.step();
    } catch (error) {
      if (error instanceof KeyboardInterrupt) {
        throw userInterruptionSignal();
      }
      throw error;
    }
  }

  executeActions(message) {
    confirmIfNeeded(message.extra.actions);
    return executeAndFormatEvenWhenPartiallyFinished(message);
  }
}

父类仍拥有真正的 run() 循环。子类没有复制整套循环,只在四个边界加用户交互:

覆盖点增加的能力
add_messages()把消息打印到终端,再交给父类保存历史
query()human mode、超限后调整预算
step()把 Ctrl+C 翻译成 Agent 能处理的消息信号
execute_actions()命令确认、退出确认、保留部分 observation

1.5 三种 mode 只改变“命令从哪来、是否确认”

mode命令生产者执行前确认是否调用模型
human用户不再确认通常不调用;输入 /y/c 后会切换并调用
confirm模型未命中 whitelist 时确认整批命令调用
yolo模型不确认调用

confirm 不是沙箱,yolo 也不是更强的 Agent。它们使用同一个 Model、Environment 和核心循环,只改变执行授权方式。

2. 5W2H

项目本课答案
What把 YAML、点号 override 和 CLI options 合并成配置,再通过工厂装配组件,并由 InteractiveAgent 加入人工控制。
Why让默认行为、一次性参数、组件选择和交互策略彼此独立,不需要为每种组合复制 Run Script 或 Agent 循环。
WhoTyper 收参数;config helper 解析;recursive_merge 决定优先级;三个工厂造对象;InteractiveAgent 与用户交互。
When程序启动时完成配置与装配;每轮 query、执行、Ctrl+C、超限和提交时触发交互子类的 hook。
Where默认值在 mini.yaml,解析在 config/__init__.py,装配在 run/mini.py,人工控制在 agents/interactive.py
HowYAML/Jinja、JSON 值解析、深合并、动态 import、构造器注入、继承覆盖和流程异常共同完成。
How much配置读取和工厂构造成本很小;真实耗时主要来自模型与命令。长 observation、反复轨迹保存和交互等待更值得关注。

3. 一张图看完整链路

流程图 1流程图 1

先记住装配方向:配置可以分别选择三个组件,但 Model 和 Environment 必须先存在,Agent 才能接收它们。

4. 逐行读 config/mini.yaml:1-151

4.1 YAML 与 Jinja 的最小语法桥

YAML/JinjaJS 类比实际含义
缩进嵌套 object表示层级,不用 {}
key: valuekey: valueobject 字段
``模板字符串反引号
{{ task }}${task}稍后由 Jinja 插值,不在 YAML 读取时执行
{% if … %}模板中的 if控制是否输出一段文本
{%- / -%}无直接语法- 同时裁掉模板两侧空白
`valuelength`value.length
`valuetojson`JSON.stringify(value)

还有三个容易被 JS 开发者忽略的 YAML 值:

  • 3. 会被读成浮点数 3.0
  • 'off' 加引号是为了保持字符串;某些 YAML 解析规则会把未加引号的 off 当布尔值。
  • '1' 也刻意保持字符串,因为进程环境变量通常必须是字符串。

第 1-3 行:agent 根节点与 system template

agent:
  system_template: |
    You are a helpful assistant that can interact with a computer.
  • 第 1 行建立 agent 配置段。
  • 第 2 行的 | 表示下面是一个多行字符串。
  • 第 3 行只是 prompt 原始文本。
  • YAML 加载时不会产生 system message;DefaultAgent.run():93 才调用 Jinja 渲染并交给 Model。

第 4-19 行:任务模板与推荐工作流

  • 第 4 行开始 instance_template
  • 第 5 行的 {{task}} 来自 agent.run(run_task);父类把 task 放进模板变量。
  • 第 7-17 行教模型分析、复现、修改和验证代码。
  • 第 18-19 行规定提交命令必须单独执行。
  • 这条 prompt 规则与 Environment 的退出协议必须同步:LocalEnvironment 只在输出第一行是完成标记且 return code 为 0 时抛 Submitted

这里体现了配置不只是“数值选项”。它还保存 Model 与 Environment 之间的文字协议。

第 21-42 行:每一轮的 action 合同

  • 第 21 行开始命令执行规则。
  • 第 23-28 行描述“模型发命令 → 子 shell 执行 → 模型看结果”的循环。
  • 第 25 行要求至少一个 command。
  • 第 30-33 行要求回复既有推理文本,也有工具调用。
  • 第 35-42 行再次强调 bash tool call 和独立提交命令。
  • 第 39 行提醒每个 action 在新 subshell 中执行,cd 和临时环境变量不会自动延续到下一 action。

Prompt 写的是 Bash,但 LocalEnvironment 使用 subprocess.Popen(..., shell=True);在 Unix 上通常由 /bin/sh 解释,不保证支持 Bash 专属语法。工具名与真实 shell 是两个层次。

第 44-53 行:正确响应示例与系统变量

  • 第 44-49 行是写给模型看的示例文本,不是 Python 真正在此执行工具。
  • 第 48 行的方括号说明意图;真正 wire format 由 Model 适配器决定。
  • 第 51-53 行插入 systemreleaseversionmachine
  • 这些值通常由 Environment 的 get_template_vars() 提供。
  • 父类使用 StrictUndefined 渲染,缺变量时直接报错,不会悄悄留下空白。

第 55-100 行:命令教程与 macOS 条件

  • 第 55-65 行给创建文件示例。
  • 第 67-87 行给 sed 修改示例。
  • 第 69-73 行是 Jinja 条件;只有 system == "Darwin" 才输出 macOS 的 sed -i '' 提示。
  • 第 89-94 行教模型用 nl 查看带行号的局部源码。
  • 第 96-100 行允许其他命令。

这些内容全是 prompt token。它提高开箱即用性,但每次新任务都要把这段长模板送给模型。

第 101-103 行:Agent 的三个默认行为

step_limit: 0
cost_limit: 3.
mode: confirm
  • step_limit: 0 表示禁用步数限制,因为父类检查以 0 < step_limit 开头。
  • cost_limit: 3. 变成 3.0
  • 成本限制在下一次 query 前检查,因此某次请求可能先让累计费用超过 3,再在下一轮停止。
  • mode: confirm 属于 InteractiveAgentConfig;模型命令默认先经过人工确认。
  • 如果错误地把这一段交给 DefaultAgent,Pydantic 默认可能忽略它不认识的 mode,配置看似存在却不生效。

第 104-110 行:Environment 环境变量

environment:
  env:
    PAGER: cat
    MANPAGER: cat
    LESS: -R
    PIP_PROGRESS_BAR: 'off'
    TQDM_DISABLE: '1'

这些值最终传给 Environment 工厂和 LocalEnvironment:

  • PAGERMANPAGER 避免命令进入等待翻页的交互程序。
  • LESS=-R 保留终端颜色转义能力。
  • pip 和 tqdm 的选项减少进度条噪声。
  • LocalEnvironment 会用 os.environ | self.config.env 合并;配置值覆盖同名宿主环境变量。

第 111-128 行:短输出完整发送,长输出保留头尾

第 113 行的分支是:

{% if output.output | length < 10000 %}

短输出分支:

  • 第 115 行发送 return code。
  • 第 116 行用 tojson 安全转义 output 中的引号、换行和反斜杠。
  • 第 117 行只在存在异常信息时加入该字段。

长输出分支:

  • 第 121 行仍保留 return code。
  • 第 122-123 行各保留头尾 5000 字符。
  • 第 124 行报告省略字符数。
  • 第 125 行加入警告。
  • 第 126 行仍可加入异常信息。

这样做减少下一轮上下文和模型费用,但 Environment 已经先捕获了完整 stdout,所以不能降低命令执行阶段的内存占用。

边界细节:长度恰好是 10000 时,因为条件是 < 10000,会进入“过长”分支,却显示 elided_chars: 0

第 129-149 行:FormatError 的两类修正提示

第一类是模型响应被 token 上限截断:

finish_reason == "length"
or (finish_reason == "tool_calls" and not has_tool_calls)

第 131 行会要求模型缩短推理并补上一个 bash tool call。

第二类是真正格式错误:

  • 未调用工具。
  • 调用了未知工具。
  • arguments 不是合法 JSON。
  • arguments 缺少 command

第 133-148 行把具体 error、正确工具名、参数形状和完成命令重新告诉模型。它不是普通日志;Model parser 会把渲染结果装进 FormatError 的 user message,Agent 再把这条消息加入历史,进入纠错轮次。

第 150-151 行:LiteLLM 宽容参数模式

model_kwargs:
  drop_params: true

这个值最终通过 **model_kwargs 交给 LiteLLM。它允许 LiteLLM 丢弃目标供应商不支持的参数,提高跨供应商兼容性;代价是某些拼错或不受支持的参数可能静默失效。

5. 逐行读 config/__init__.py:1-64

第 1-9 行:依赖与内置配置目录

  • 第 1 行说明模块职责。
  • 第 3 行 json 用于解析内联 override 的值。
  • 第 4 行 os 用于读取 MSWEA_CONFIG_DIR
  • 第 5 行 Path 近似 Node 的 path 工具加文件对象方法。
  • 第 7 行 yaml 是 PyYAML。
  • 第 9 行用当前模块的 __file__ 定位内置 config 目录。

pyproject.toml 会把 config 目录打进 Python package,因此 pip 安装后仍能按名字查找 mini.yaml

第 12-16 行:把名字标准化为 .yaml

config_spec = Path(config_spec)
if config_spec.suffix != ".yaml":
    config_spec = config_spec.with_suffix(".yaml")

结果示例:

输入标准化结果
minimini.yaml
config/teamconfig/team.yaml
foo.ymlfoo.yaml
foo.YAMLfoo.yaml

with_suffix() 是替换最后一个 suffix,不是简单在末尾拼接。

第 17-23 行:配置文件搜索优先级

候选路径按顺序排列:

  1. 调用者直接给出的路径,或当前目录中的同名文件。
  2. $MSWEA_CONFIG_DIR 下的文件。
  3. 内置 config/
  4. 内置 config/extra/
  5. 内置 config/benchmarks/

第一个存在的候选获胜。JavaScript 近似:

const chosen = candidates.find((path) => existsSync(path));

当前目录优先意味着 -c mini 可能被项目目录中的同名 mini.yaml 遮蔽。无参数启动使用第 22 行计算出的内置绝对路径,通常不走这种名字遮蔽。

第 24-28 行:找到即返回,否则报告全部候选

  • 第 24 行逐个遍历。
  • 第 25 行只调用 exists(),没有确认它是普通文件。
  • 第 26 行返回第一个存在的 Path。
  • 第 28 行抛 FileNotFoundError,并把所有尝试位置放进消息。

如果候选恰好是目录,get_config_path() 会返回它,之后 read_text() 才以另一个异常失败。

第 31-39 行:只按第一个 = 切分

key, value = config_spec.split("=", 1)

1 表示最多切一次,所以 value 自身可以包含等号:

model.token=a=b=c

会得到 key model.token 和字符串 value a=b=c

第 40-43 行:先按 JSON 解析,失败就保留字符串

这最像:

function tryJsonParse(value) {
  try {
    return JSON.parse(value);
  } catch {
    return value;
  }
}
spec 中的 valuePython 结果
42int 42
0.5float 0.5
trueTrue
falseFalse
nullNone
[1,2]list
{"x":1}dict
yolo字符串 "yolo"
True字符串 "True",因为它不是合法 JSON

这里没有 eval(),所以 value 不会被当成 Python 代码执行。

第 44-53 行:点号路径变嵌套 dict

agent.cost_limit=0

会变成:

{"agent": {"cost_limit": 0}}
  • 第 44 行按点号切 key。
  • 第 45-46 行拒绝空片段,例如 model..name=x
  • 第 47 行创建根 dict。
  • 第 48 行让 current 指向当前正在构建的那层 dict。
  • 第 49-51 行逐层创建并移动引用。
  • 第 52 行把最终 value 放到叶子 key。
  • 第 53 行返回结果。

它最像 lodash.set({}, "agent.cost_limit", 0),但这里只负责构造单条 override,不负责与旧配置合并。

第 56-61 行:一条 spec 的分流器

if isinstance(config_spec, str) and "=" in config_spec:
    return _key_value_spec_to_nested_dict(config_spec)

只要输入是字符串且含 =,就一律视为 key/value override。否则:

  1. 调用 get_config_path()
  2. 用 UTF-8 读取文本。
  3. yaml.safe_load() 解析。

safe_load() 禁止 PyYAML 构造任意 Python 对象,但函数没有检查 YAML 顶层一定是 dict。空文件会返回 None,列表或标量也能被解析,错误可能延迟到合并阶段才出现。

如果确实需要读取文件名中含 = 的文件,可以传 Path;这个分支只把字符串中的 = 当 override 标志。

第 64 行:显式导出列表

__all__ 控制 from minisweagent.config import * 时导出什么。它把 _key_value_spec_to_nested_dict 也列出来了;下划线通常表示内部 helper,因此这里的公开语义稍显矛盾。

6. 配置合并:后者覆盖前者,UNSET 跳过

真正合并函数在 utils/serialize.py:6-29。关键规则:

config = recursive_merge(first, second, third)
  • 从左到右处理。
  • 后面的标量覆盖前面的标量。
  • 两边都是 dict 时递归合并。
  • list 整体替换,不拼接。
  • 整个参数是 None 时跳过。
  • 某字段的值是 UNSET 时跳过。
  • 函数创建新 dict,不直接修改原始配置。

示例命令:

mini \
  -c mini.yaml \
  -c agent.cost_limit=5 \
  -c environment.env.PAGER='"less"' \
  --cost-limit 0 \
  --yolo

关键结果:

字段mini.yaml后续 -cCLI overlay最终值
agent.cost_limit3.0500
agent.modeconfirm未设置yoloyolo
environment.env.PAGERcatless未设置,即 UNSETless

Typer 的 list option 有一个重要产品行为:用户一旦显式提供 -c,默认 list 不会再自动附在前面。因此只覆盖一个字段时要写:

mini -c mini.yaml -c agent.cost_limit=1

只写下面这一条会失去 mini.yaml 中的 system template、instance template 和其他默认值:

mini -c agent.cost_limit=1

7. 逐行读 run/mini.py:1-109

第 1-4 行:可执行脚本声明与入口说明

  • 第 1 行 shebang 允许 Unix 把文件当 Python 脚本执行。
  • 第 3 行说明它是本地运行入口,也是 mini 命令的默认 executable。
  • 第 4 行指向使用文档。

安装后真正的命令映射由 pyproject.toml 的 console script 配置完成;shebang 主要服务直接执行文件的场景。

第 6-20 行:四类依赖

标准库:

  • os 读取环境变量。
  • Path 表示默认配置与轨迹路径。
  • Any 标注 main() 的返回值。

CLI/UI:

  • typer 把函数参数变成命令行 options。
  • Rich Console 输出有颜色的状态文本。

三个组件工厂:

  • get_agent
  • get_environment
  • get_model

配置与辅助逻辑:

  • _multiline_prompt 收多行任务。
  • builtin_config_dirget_config_from_spec 定位并解析配置。
  • configure_if_first_time 处理首次运行设置。
  • UNSETrecursive_merge 处理覆盖优先级。

第 22-23 行:模块 import 时就计算默认路径

DEFAULT_CONFIG_FILE = Path(os.getenv("MSWEA_MINI_CONFIG_PATH", builtin_config_dir / "mini.yaml"))
DEFAULT_OUTPUT_FILE = global_config_dir / "last_mini_run.traj.json"
  • MSWEA_MINI_CONFIG_PATH 可以替换默认 mini 配置。
  • 默认轨迹写到用户全局配置目录。
  • 两个表达式在模块 import 时求值,不是每次调用 main() 时重新读取环境变量。
  • 顶层 minisweagent import 还会创建全局配置目录并加载 .env,所以 import 入口并非完全无副作用。

第 26-47 行:Rich 帮助文本

_HELP_TEXT 是命令总说明。_CONFIG_SPEC_HELP_TEXT 重点解释:

  • -c 可以接文件名、路径或 key/value。
  • 显式使用 -c 后,默认 config 不再自动使用。
  • 多个配置递归合并。
  • 示例展示正确和错误的覆盖方式。

这些字符串只影响 --help,不参与运行时配置。

第 49-50 行:创建输出器和 Typer app

console = Console(highlight=False)
app = typer.Typer(rich_markup_mode="rich")
  • 关闭自动高亮,避免任意 task/config 文本被 Rich 猜测着色。
  • Typer app 开启 Rich markup,让帮助文本中的 [bold] 等标记生效。

第 53-67 行:把 main() 参数声明成 CLI

@app.command(...) 相当于把 main 注册进 CLI router。# fmt: off/on 让 formatter 保留这一组紧凑 option 声明。

Python 参数CLI作用
56model_name-m/--model覆盖模型名
57model_class--model-class选模型实现短名或完整路径
58agent_class--agent-class选 Agent 实现
59environment_class--environment-class选 Environment 实现
60task-t/--task问题描述
61yolo-y/--yolo取消模型命令确认
62cost_limit-l/--cost-limit成本限制;0 表示关闭
63config_spec-c/--config可重复的配置层
64output-o/--outputtrajectory 文件
65exit_immediately--exit-immediatelyAgent 提交时不再询问新任务

str | None 对应 TypeScript string | null。Typer 根据类型、默认值和 Option(...) 元数据完成参数解析。

第 68 行:首次运行配置

configure_if_first_time() 可能读取或创建全局设置,并出现交互提示。它必须在构建 Model 前运行,因为模型名或 API 配置可能来自这一步。

第 70-72 行:按顺序加载每个 config spec

configs = [get_config_from_spec(spec) for spec in config_spec]

列表推导式近似:

const configs = configSpec.map(getConfigFromSpec);

顺序不能乱,因为后面的层会覆盖前面的层。

第 73-91 行:把显式 CLI 值做成最后一层

这段 dict 分成四个命名空间:

  • run:task。
  • agent:Agent class、mode、预算、退出确认和轨迹路径。
  • model:Model class 与 name。
  • environment:Environment class。

逐个看 falsy 处理:

  • task or UNSETNone 和空字符串都视为没提供,所以空 task 不能通过这个参数保留下来。
  • agent_class or UNSET:空字符串视为没提供。
  • "yolo" if yolo else UNSET:没有 --yolo 时不覆盖 YAML 的 confirm
  • cost_limit if cost_limit is not None else UNSET:显式 0 会被保留。
  • False if exit_immediately else UNSET:只有 flag 为真才覆盖 confirm_exit
  • output or UNSET:程序化传 None 可以避免覆盖配置中的 output path。

这个 CLI dict 最后 append,所以所有真实 CLI 值拥有最高优先级。

第 92 行:递归合并所有层

config = recursive_merge(*configs)

*configs 类似 JavaScript spread 参数:

recursiveMerge(...configs);

此时才得到最终的四段配置,而不是在每个 get_config_from_spec() 中直接修改同一个全局对象。

第 94-97 行:没有 task 时进入多行输入

if (run_task := config.get("run", {}).get("task", UNSET)) is UNSET:
  • := 是 walrus operator:在条件里取值并赋给 run_task
  • 连续 .get() 避免 run 段不存在时立刻 KeyError
  • is UNSET 检查是否正是那个唯一 sentinel 对象,不能用 ==
  • 没有 task 时显示提示、读取多行文本,再打印确认。

第 99-102 行:三个工厂与最终运行

model = get_model(config=config.get("model", {}))
env = get_environment(config.get("environment", {}), default_type="local")
agent = get_agent(model, env, config.get("agent", {}), default_type="interactive")
agent.run(run_task)

精确装配顺序:

  1. Model 工厂根据 model name/class 创建模型。
  2. Environment 工厂默认选择 LocalEnvironment。
  3. Agent 工厂默认选择 InteractiveAgent,并注入前两个实例。
  4. 调用统一 Agent Protocol 的 run(task)

mini.yaml 本身没有 model name。get_model() 还会尝试配置、环境变量和首次设置;都没有时会抛出明确错误。

第 103-105 行:输出提示和可测试返回值

  • 如果最终 Agent config 中有 output_path,打印轨迹保存位置。
  • 返回 agent 实例,方便 Python 调用者和测试检查最终 config、messages 等状态。
  • 普通 shell CLI 用户主要看到输出,不会消费这个返回值。

第 108-109 行:脚本直跑保护

if __name__ == "__main__":
    app()

近似 JavaScript 的“仅当当前文件是入口时启动”。模块被测试 import 时不会自动运行 CLI;直接执行该文件时才调用 Typer app。

8. 三个工厂究竟各做了什么

Model 工厂

get_model(config=...) 会:

  1. 解析 model name。
  2. 深拷贝 config,避免 pop() 改坏调用者对象。
  3. 解析 model_class 短名或完整路径。
  4. 写入最终 model name。
  5. model_class(**config) 创建实例。

Environment 工厂

get_environment(config, default_type="local") 会:

  1. 深拷贝 config。
  2. 弹出 environment_class,缺失时用 local
  3. 解析短名或完整路径。
  4. 用剩余配置构造 Environment。

Agent 工厂

get_agent(model, env, config, default_type="interactive") 会:

  1. 深拷贝 config。
  2. 弹出 agent_class,缺失时用 interactive
  3. 解析短名或完整路径。
  4. 调用 agent_class(model, env, **config)

三者都允许完整 import path,这让外部扩展不必先修改仓库映射表。但动态 import 也意味着配置必须被视为可信代码入口。

9. 逐行读 agents/interactive.py:1-209

第 1-7 行:模块先声明三种模式

docstring 已经给出最重要的差异:

  • human:用户命令立即执行。
  • confirm:模型命令未命中白名单时询问用户。
  • yolo:模型命令直接执行。

“立即”表示不再经过第二次确认,不表示异步或并行。

第 9-19 行:依赖按职责分组

  • re:白名单正则。
  • sys:检查 stdin 是否连接交互终端。
  • Literal:限制 mode 的三个字符串值。
  • NoReturn:标注函数不会正常返回,因为它一定抛异常。
  • Rich ConsoleRule:终端输出和分隔线。
  • AgentConfigDefaultAgent:父配置与父类。
  • _multiline_promptprompt_session:用户输入。
  • LimitsExceededSubmittedTimeExceededUserInterruption:流程信号。
  • get_content_string:兼容不同模型 message 的 content 形状。

第 21 行:专用 Console

模块创建自己的 Rich Console,并关闭自动高亮。它负责显示模型回复、user observation、exit message 和提示文本。

第 24-30 行:配置类继承父配置

class InteractiveAgentConfig(AgentConfig):

它自动继承 system template、instance template、step/cost/time limits 和 output path,再新增:

  • 第 25 行 mode,只允许 human/confirm/yolo,默认 confirm
  • 第 27 行 whitelist_actions,匹配时永不确认。
  • 第 29 行 confirm_exit,默认提交时再问一次用户。

这近似 TypeScript:

interface InteractiveAgentConfig extends AgentConfig {
  mode: "human" | "confirm" | "yolo";
  whitelistActions: string[];
  confirmExit: boolean;
}

第 33-38 行:子类、slash command 映射和构造器

  • 第 33 行继承 DefaultAgent。
  • 第 34 行把 /u/c/y 映射到三种 mode。
  • 第 36 行的 *args 收位置参数,**kwargs 收具名配置。
  • config_class=InteractiveAgentConfig 是可替换的配置类注入点。
  • 第 37 行调用父构造器;父类仍负责 model、env、messages、cost 等状态。
  • 第 38 行设置 cost_last_confirmed,当前仓库没有其他读取位置,像未完成或遗留字段。

Python 的 super() 对应 JS 的 super(...)super.method()

第 40-41 行:把用户事件翻译成流程异常

def _interrupt(...) -> NoReturn:
    raise UserInterruption(...)

它创建一条 role=user message,并在 extra.interrupt_type 记录类型。父类 run() 会捕获 InterruptAgentFlow,把异常携带的 message 加入历史,然后继续下一轮。

因此这里的异常不是意外 crash,而是“跳出当前 step,并向对话加入用户反馈”的控制信号。

第 43-56 行:打印后仍要调用父类保存消息

第 45 行遍历本次新增消息。第 46 行:

  • 优先读 Chat 风格的 role
  • 没有 role 时读 Responses 风格的 type
  • 再用 get_content_string() 提取统一可打印文本。

assistant message 会显示当前 step 和累计费用;其他 message 显示角色标题。第 55 行关闭 markup,避免模型输出中的 Rich 标记被执行为样式。

最关键是第 56 行:

return super().add_messages(*messages)

如果只打印却不调用父类,终端看起来正常,但 self.messages 不会更新,下一轮模型上下文、退出判断和 trajectory 都会损坏。

第 58-71 行:human mode 不调用模型,直接造 action message

第 60 行只在 mode 为 human 时进入。

第 61 行同时使用两种 Python 语法:

  • command := ...:调用输入 helper 并保存结果。
  • match/case:近似 JS switch

如果输入 /y/c,helper 已先改变 mode;case 中 pass 后继续执行下面的模型 query。

其他输入被包装为:

{
    "role": "user",
    "content": "User command: ...",
    "extra": {"actions": [{"command": command}]},
}
  • 它没有 tool_call_id,因为这不是模型工具调用。
  • 第 70 行先把人工命令消息放进历史。
  • 第 71 行返回这条 message。
  • 父类 step() 随即把 extra.actions 交给 execute_actions()

第 72-79 行:普通模型 query 与 wall-time 特例

  • Rich status 在模型等待期间显示 spinner。
  • 第 74 行调用父类 query,父类负责预算检查、计数、模型请求、费用累计和保存 assistant message。
  • TimeExceeded 必须先捕获并直接重抛。

原因是 TimeExceeded 继承 LimitsExceeded。如果顺序反过来,它会被更宽泛的分支截获,要求用户修改 step/cost limit,但 wall clock 已经过期,下一轮仍会立即失败。

这类似 JavaScript 中先捕获更具体的 error type,再处理父类型。

第 80-94 行:step/cost 超限后的交互处理

  • 没有 TTY 时直接重抛,让父类把 LimitsExceeded exit message 保存到 trajectory。
  • 这样 CI、sandbox 或 stdin 重定向场景不会因为 input() 抛 EOFError 而崩溃。
  • 有 TTY 时打印旧限制与当前花费。
  • 第 92-93 行读取新的 step/cost limit,并显式转换为 intfloat
  • 第 94 行直接调用父类 query 再试一次。

这里没有 catch 数字转换错误;输入非数字会按项目“让异常暴露问题”的风格直接失败。

第 96-107 行:安全检查 stdin 是否可交互

@staticmethod 表示方法不需要 self。它检查:

sys.stdin is not None and sys.stdin.isatty()

某些已关闭或异常的 stream 在 isatty() 时会抛 ValueError/OSError,所以这里返回 False。这个 catch 是为了判断能力,而不是吞掉 Agent 业务错误。

第 109-122 行:Ctrl+C 变成可继续的 user message

  • 第 112 行先打印水平分隔线。
  • 第 113 行执行父类完整 step。
  • KeyboardInterrupt 通常来自 Ctrl+C。
  • 捕获后询问用户评论、命令或 mode command。
  • 空输入或仅切 mode 时,用默认文本 Temporary interruption caught.
  • 最后 _interrupt() 抛 UserInterruption。

父类 run() 捕获后会把“Interrupted by user”消息加入上下文,下一轮模型能看到用户干预,而不是整个进程直接消失。

第 124-139 行:确认整批 action,顺序执行,并保留部分结果

第 126 行从 message 的 extra.actions 读取统一 action。第 127 行提取命令文本用于提示。第 128 行准备 outputs。

try 内:

  1. 第 130 行判断是否需要确认整批 commands。
  2. 第 131-132 行按顺序执行每个 action。

如果 Environment 抛 Submitted

  • 第 133 行捕获。
  • 第 134 行决定直接提交、切 human mode,还是把新任务加入对话。

finally 无论正常、拒绝、提交或部分执行都会调用 Model formatter。这对 tool-call 模型尤其重要:已经发出的每个 tool call 最终都需要对应 tool result;formatter 可以为未执行 action 补占位结果。

如果第二条 action 提交,第一条 action 的 output 仍保留,不会因为流程跳转而丢失。

第 141-142 行:未使用的 observation helper

_add_observation_messages() 做的事情与第 136-138 行相同,但当前源码和测试没有调用它。它可能是重构残留,删除前仍应检查外部子类是否依赖这个半私有方法。

第 144-160 行:Agent 想完成时的第二道选择

confirm_exit=False 时,第 160 行直接重新抛原 Submitted,父类 run 保存 exit message 并结束。

confirm_exit=True 时:

  • Enter:没有进入任何分支,最后重新抛 Submitted,正式退出。
  • /u:切到 human mode,并抛 UserInterruption 继续运行。
  • /y/c:mode 已改变,递归重新询问退出选择。
  • 任意其他非空文本:作为 UserNewTask 加入对话,继续同一个 Agent。

因此 --exit-immediately 的实现不是绕过 Agent,而是最终把 confirm_exit 覆盖为 False。

第 162-163 行:单条 command 是否需要确认

return self.config.mode == "confirm" and not any(
    re.match(regex, action) for regex in self.config.whitelist_actions
)
  • 只有 confirm mode 才可能返回 True。
  • re.match() 只从字符串开头匹配,不是在任意位置搜索。
  • 任意一个正则匹配就进入白名单。
  • whitelist 为空时 any([]) 是 False,所以 confirm mode 会询问。

边界:空正则 "" 会匹配所有命令;无效正则会直接抛 re.error

第 165-182 行:一条不安全,整批都先询问

第 166 行用 any(...) 检查所有 command。只要其中一条需要确认,就对整个 batch 发出一次确认提示。

用户输入结果:

  • Enter:确认当前 batch。
  • /y:helper 已把 mode 改成 yolo,同时确认当前 batch。
  • /u:不执行命令,切 human mode,并生成 UserRejection。
  • 其他文本:不执行命令,把文本作为拒绝原因反馈给模型。

这里不是逐条批准。一个 batch 中四条命令只会出现一次总确认。

第 184-209 行:统一处理普通、帮助、多行和切模式输入

  • 第 186 行先输出 prompt。
  • _multiline=True 时,第 188 行调用多行输入。
  • 否则第 189 行读单行。
  • /m:递归改用多行输入。
  • /h:显示当前 mode 和四条帮助命令,再递归询问。
  • /u/c/y:检查是否已经处于目标 mode;重复命令时显示提示并递归询问。
  • 真正切换时直接修改 self.config.mode,打印结果并返回原 slash command。
  • 普通文本原样返回给调用者解释。

反复输入 /h/m 或重复 mode command 会不断增加 Python 调用栈。实际人工使用很难触顶,但循环实现比递归更稳健。

10. 子类改了什么,哪些仍完全继承

方法DefaultAgent 行为InteractiveAgent 的变化
run()初始化消息,循环 step,捕获流程异常,保存轨迹完全继承
get_template_vars()合并 Agent/Env/Model/额外变量完全继承
_render_template()StrictUndefined Jinja 渲染完全继承
add_messages()写入 self.messages先打印,再调用父类写入
query()检查限制并调用 Model增加 human mode、TTY 与限额调整
step()query 后 execute增加分隔线与 Ctrl+C 处理
execute_actions()直接顺序执行增加确认、退出确认和 finally 格式化
serialize()/save()生成和保存 trajectory完全继承;序列化会记录实际子类名和扩展 config

学习继承时不要从上到下重新理解父类全部代码。先列出 override 方法,再把每个 override 与同名父方法做差分阅读。

11. 一次真实启动的精确时序

以这条命令为例:

mini -c mini.yaml -c agent.cost_limit=1 --yolo --exit-immediately -t "Fix issue"

时序:

  1. Typer 把字符串、布尔 flag 和 list options 变成 Python 参数。
  2. configure_if_first_time() 确保模型基础设置存在。
  3. mini.yaml 变成完整 dict。
  4. agent.cost_limit=1 变成局部嵌套 dict。
  5. CLI overlay 写入 task、mode=yoloconfirm_exit=False
  6. recursive_merge 得到最终配置。
  7. Model 工厂选择供应商适配器并创建实例。
  8. Environment 工厂默认创建 LocalEnvironment。
  9. Agent 工厂默认创建 InteractiveAgent,并注入前两个对象。
  10. run("Fix issue") 生成 system/user 初始消息。
  11. query() 调模型;yolo mode 不改变 query。
  12. execute_actions() 因 yolo 跳过确认,直接让 LocalEnvironment 执行。
  13. Environment 抛 Submitted 时,因为 confirm_exit=False,立即结束。
  14. 父类 finally 保存 trajectory,入口打印保存位置。

12. 主要使用位置

配置 helper

  • run/mini.py:本地 CLI 的主要使用者。
  • run/benchmarks/programbench.py:批量 ProgramBench 配置。
  • run/benchmarks/swebench.py:批量 SWE-bench 配置。
  • run/benchmarks/swebench_single.py:单实例 benchmark 配置。
  • tests/config/test_init.py:点号解析、类型和值、UTF-8 与内置配置发现。

mini.yaml

  • run/mini.py:22:默认配置绝对路径。
  • tests/agents/test_default.py:tool-call Agent 测试配置。
  • tests/agents/test_interactive.py:三种 mode 和交互路径的配置。
  • models/litellm_model.py:消费 observation、format-error 和 model kwargs。
  • agents/default.py:消费 system/instance template 和 limits。
  • environments/local.py:消费 environment env/timeout/cwd。

InteractiveAgent

  • agents/__init__.py:短名 interactive 的工厂映射。
  • run/mini.py:缺少显式 agent class 时把它作为默认类型。
  • tests/agents/test_interactive.py:确认、拒绝、模式切换、Ctrl+C、超限、提交和新任务。

13. 配套测试在证明什么

tests/config/test_init.py

覆盖:

  • 单层和多层 dotted key。
  • 字符串、数字、负数、布尔、null、list 和 object。
  • value 中的空格与引号。
  • 空 key 片段报错。
  • YAML 文件读取。
  • C/ASCII locale 下仍用 UTF-8。
  • 所有内置 YAML 都能按 stem 查到。

tests/utils/test_serialize.py

覆盖:

  • 后配置覆盖前配置。
  • 多层 dict 递归合并。
  • 标量与 dict 互相覆盖。
  • list 整体替换。
  • 输入 dict 不被修改。
  • NoneUNSET 跳过。
  • 深层只有 UNSET 时仍会过滤叶子值。

tests/run/test_cli_integration.py

关键用例验证:

  • CLI model name 进入最终 model config。
  • --yolo 写入 agent.mode=yolo
  • 没有 --yolo 时不覆盖 YAML。
  • 显式 cost_limit=0 不会丢失。
  • task 缺失时调用多行输入。
  • --exit-immediately 写入 confirm_exit=False
  • Typer help 展示关键 options。

tests/agents/test_interactive.py

它用 DeterministicModel 的不同消息方言验证:

  • 正常确认与提交。
  • 拒绝 action 后模型恢复。
  • human/confirm/yolo 互相切换。
  • whitelist 跳过确认。
  • /h/m 和 mode commands。
  • Ctrl+C 转成用户消息。
  • 超限时 TTY 与非 TTY 的差异。
  • 提交时退出、切 human mode 或加入新任务。
  • confirm_exit 默认值和显式配置。
  • tool-call 的 partial outputs 仍保持消息协议完整。

本地无 API 验证结果

tests/config/test_init.py
tests/utils/test_serialize.py
tests/agents/test_init.py
64 passed

tests/agents/test_interactive.py
108 passed

tests/run/test_cli_integration.py -k 'not help'
11 passed, 12 deselected

07_configuration_factories_interactive.py
exit code 0

python -m minisweagent.run.mini --help 也已成功显示全部 options。直接运行完整 CLI integration 文件时,有 9 个 console-script help 用例因为当前虚拟环境没有安装 minimini-swe-agentmini-extra 可执行入口而失败;这是本地安装形态问题,不是配置合并或 Typer app 行为失败,也没有触发 API 请求。

14. 测试缺口与容易误读之处

  1. 没有完整测试五个 config 搜索位置的优先级和 MSWEA_CONFIG_DIR
  2. 没有覆盖 .yml.YAML 的 suffix 替换语义。
  3. 没有验证空 YAML、list 或 scalar 顶层会得到清晰错误。
  4. 没有把 mini.yaml 三段配置统一送进目标配置 schema 做一次启动前验证。
  5. 10000 字符边界没有直接测试。
  6. task or UNSET 把空字符串当未设置的行为缺少明确契约测试。
  7. whitelist 的空正则、非法正则和复杂正则性能缺少测试。
  8. prompt helper 的递归深度没有测试。
  9. cost_last_confirmed_add_observation_messages 没有调用路径测试。
  10. 动态 import 目标模块内部依赖失败时,工厂可能把真实 ImportError 包装成“Unknown type”,缺少诊断测试。

15. 可维护性、性能与安全审查

可维护性

  1. 配置错误发现较晚。 YAML 先作为裸 dict 合并,直到三个工厂构造 Pydantic config 才校验。增加启动前 schema 验证会更早报错;风险是插件配置和不同实现允许的字段不完全相同。
  2. 未知字段可能静默失效。 默认 Pydantic 行为可能忽略额外字段,拼错 option 或把 Interactive 字段交给 DefaultAgent 时不一定立即失败。改成禁止 extra 会提高正确性,但可能破坏现有宽松配置。
  3. 大 prompt 同时绑定多个协议。 修改提交标记、tool 名称或 shell 语义时,需要同步 Model parser、Environment 和测试。
  4. 两个疑似残留成员。 cost_last_confirmed_add_observation_messages 当前没有仓库内调用。删除能减小认知负担,但半私有方法可能已被外部子类使用。
  5. 递归 prompt 可改循环。 循环能消除极端递归深度风险;重构时要保留 /m/h、重复 mode 和提交确认的精确返回语义。

性能

  • 几份小 YAML 和一次深合并几乎不是瓶颈。
  • observation 模板会反复构造 Jinja Template;长任务可考虑缓存编译结果。
  • 10000 字符截断只减少模型上下文,不减少 Environment 捕获完整输出的内存。
  • _should_ask_confirmation() 对每个 action 和每条 whitelist regex 做匹配;通常列表很小,收益远低于 Model/API 延迟。
  • 父类每轮保存完整且不断增长的 trajectory,长任务累计写入量可能接近 O(n²)。
  • action 当前顺序执行;并行化会改变共享工作目录和副作用顺序,不是无风险优化。

安全

  1. LocalEnvironment 是权限边界。 它用当前用户权限运行 shell。confirm 只是人工闸门,不是隔离。
  2. yolo 移除主要确认闸门。 面对不可信仓库或 prompt injection,应优先换 Docker/Bubblewrap 等 Environment。
  3. human mode 也直接执行。 用户命令不经过模型,但仍有当前 Environment 的全部权限。
  4. whitelist 必须谨慎。 空正则匹配所有命令,过宽正则可能让危险复合命令免确认;re.match 只约束字符串开头,不理解 shell AST。
  5. 配置是可信代码边界。 它可以指定动态 class path;Jinja 也不是面向不可信模板的 sandbox。
  6. safe_load 只保护 YAML 反序列化。 它不能阻止危险 class、危险 shell、密钥泄漏或 prompt injection。
  7. 当前目录配置可遮蔽短名。 敏感运行应使用明确绝对路径,而不是依赖 -c mini 搜索。
  8. 不要把密钥写进 CLI override。 命令可能出现在 shell history、进程信息和保存的配置/trajectory 中。
  9. Environment 模板变量可能含宿主环境变量。 不可信 Jinja 模板可能引用并把敏感值发送给模型。

改进风险速查

改进收益兼容风险
exists()is_file()更早拒绝目录
YAML 顶层强制 dict错误更清晰依赖非常规 YAML 的调用者会失败
Pydantic 禁止未知字段抓拼写错误插件和共享 config 的额外字段可能失效
启动时预编译 whitelist regex更早发现坏正则旧配置会更早失败
禁止空 whitelist regex避免意外全放行可能破坏有意的全白名单设置
prompt 递归改循环消除栈增长要精确保持 slash command 行为
默认隔离 Environment显著提高安全性安装、性能、文件挂载和平台体验改变
配置/trajectory 统一脱敏降低密钥泄漏降低完整复现和供应商排错能力

16. 无 API、无 shell 小练习

练习脚本已作为本文附件提供。

它同时练四件事:

  1. get_config_from_spec("mini") 读取内置 YAML。
  2. 用点号 spec 把 mode 覆盖为 yolo,并把 confirm_exit 解析成真正的 False。
  3. 用三个工厂创建 DeterministicModel、MemoryEnvironment 和默认 InteractiveAgent。
  4. 让 InteractiveAgent 运行两轮,但 MemoryEnvironment 只记录字符串,不调用 subprocess。

运行:

MSWEA_SILENT_STARTUP=1 PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=src \
  .venv/bin/python exercises/07_configuration_factories_interactive.py

终端可能显示一条 Input is not a terminal 警告,因为 InteractiveAgent 的 prompt session 检测到当前 stdin 不是 TTY;练习使用 yolo,不会真的读取输入。

预期关键输出:

base mode: confirm
merged mode: yolo
agent class: InteractiveAgent
environment class: MemoryEnvironment
...
result: {'exit_status': 'Submitted', 'submission': 'offline exercise complete'}
recorded commands: ['remember merged config', 'finish']

逐段理解:

  • base mode 仍为 confirm,证明 merge 没有修改原始 dict。
  • 后加载的 dotted override 获胜,所以 merged mode 是 yolo。
  • Model 工厂通过 model_class=deterministic 创建离线磁带模型。
  • Environment 工厂通过完整路径 __main__.MemoryEnvironment 动态导入练习类。
  • Agent config 没写 agent_class,但 default_type="interactive" 仍创建 InteractiveAgent。
  • yolo mode 让两条 action 无需 prompt。
  • finish 由 MemoryEnvironment 翻译成 Submitted,confirm_exit=False 让它直接结束。

动手改三处:

  1. 删除 get_config_from_spec("agent.mode=yolo"),观察程序为何开始等待确认;不要在无人值守环境中继续卡住,看到 prompt 后按 Ctrl+C。
  2. agent.confirm_exit=false 改成 agent.confirm_exit=true,观察完成时为何多一道选择。
  3. 把 Environment class 改成不存在的路径,阅读工厂产生的错误消息并定位解析失败发生在哪一层。

17. 三道检查题(请先回答,不要查答案)

  1. mini -c mini.yaml -c agent.cost_limit=5 --cost-limit 0 最终的 cost limit 是多少;为什么这里必须用 is not NoneUNSET,不能用普通的 or
  2. run/mini.py 为什么必须先创建 Model 和 Environment,再创建 Agent;没有写 agent_class 时,哪个参数让工厂最终选择 InteractiveAgent?
  3. confirm mode 中,一批命令只有一条未命中 whitelist 时会发生什么;用户输入 /u 后,当前 batch、Agent mode 和下一轮分别怎样变化?