本系列基于 mini-SWE-agent 源码学习整理;源码版本固定为 Asuhe404/mini-swe-agent@388da74,便于长期核对实现。
系列导航
返回路线图 · 上一篇:06. Model 适配与 action 翻译:把模型方言变成统一命令 · 下一篇:08. 测试驱动扩展:用可预测替身保护 Agent 三层契约
适合:零 Python 基础、已有 JavaScript 基础。
重点文件:
src/minisweagent/config/mini.yamlsrc/minisweagent/config/__init__.pysrc/minisweagent/run/mini.pysrc/minisweagent/agents/interactive.py
本节目标:关闭源码后,能够从一条 mini 命令追到最终的 Model、Environment 和 InteractiveAgent 实例;能够解释后配置为什么覆盖前配置;能够说清 human、confirm、yolo 三种模式怎样改变同一个 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);
核心不是某一个语法,而是四步:
- YAML 提供完整默认对象。
-c key=value和 CLI options 提供局部覆盖。- 深合并时,越靠后的层优先级越高。
- 三个工厂把三个配置段变成真实对象,再把 Model 和 Environment 注入 Agent。
1.2 UNSET 最像“不要覆盖”,不是 null
JavaScript 中常用 undefined 表示“调用者没有提供这个字段”,用 null 表示“调用者明确提供了空值”。本项目额外创建了一个唯一对象:
UNSET = object()
它的语义是:
这一层对该字段没有意见,合并时跳过它,保留较早配置的值。
对比:
| 值 | 含义 | 合并结果 |
|---|---|---|
UNSET | 没提供,不要覆盖 | 保留旧值 |
None | 明确给出 Python 空值 | 通常覆盖旧值为 None |
0 | 明确关闭限制 | 必须保留并覆盖旧值 |
False | 明确关闭布尔选项 | 必须保留并覆盖旧值 |
因此 cost_limit 不能写成 cost_limit or UNSET。0 是合法值,却是 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 循环。 |
| Who | Typer 收参数;config helper 解析;recursive_merge 决定优先级;三个工厂造对象;InteractiveAgent 与用户交互。 |
| When | 程序启动时完成配置与装配;每轮 query、执行、Ctrl+C、超限和提交时触发交互子类的 hook。 |
| Where | 默认值在 mini.yaml,解析在 config/__init__.py,装配在 run/mini.py,人工控制在 agents/interactive.py。 |
| How | YAML/Jinja、JSON 值解析、深合并、动态 import、构造器注入、继承覆盖和流程异常共同完成。 |
| How much | 配置读取和工厂构造成本很小;真实耗时主要来自模型与命令。长 observation、反复轨迹保存和交互等待更值得关注。 |
3. 一张图看完整链路
先记住装配方向:配置可以分别选择三个组件,但 Model 和 Environment 必须先存在,Agent 才能接收它们。
4. 逐行读 config/mini.yaml:1-151
4.1 YAML 与 Jinja 的最小语法桥
| YAML/Jinja | JS 类比 | 实际含义 |
|---|---|---|
| 缩进 | 嵌套 object | 表示层级,不用 {} |
key: value | key: value | object 字段 |
| ` | ` | 模板字符串反引号 |
{{ task }} | ${task} | 稍后由 Jinja 插值,不在 YAML 读取时执行 |
{% if … %} | 模板中的 if | 控制是否输出一段文本 |
{%- / -%} | 无直接语法 | - 同时裁掉模板两侧空白 |
| `value | length` | value.length |
| `value | tojson` | 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 行插入
system、release、version、machine。 - 这些值通常由 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:
PAGER、MANPAGER避免命令进入等待翻页的交互程序。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")
结果示例:
| 输入 | 标准化结果 |
|---|---|
mini | mini.yaml |
config/team | config/team.yaml |
foo.yml | foo.yaml |
foo.YAML | foo.yaml |
with_suffix() 是替换最后一个 suffix,不是简单在末尾拼接。
第 17-23 行:配置文件搜索优先级
候选路径按顺序排列:
- 调用者直接给出的路径,或当前目录中的同名文件。
$MSWEA_CONFIG_DIR下的文件。- 内置
config/。 - 内置
config/extra/。 - 内置
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 中的 value | Python 结果 |
|---|---|
42 | int 42 |
0.5 | float 0.5 |
true | True |
false | False |
null | None |
[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。否则:
- 调用
get_config_path()。 - 用 UTF-8 读取文本。
- 用
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 | 后续 -c | CLI overlay | 最终值 |
|---|---|---|---|---|
agent.cost_limit | 3.0 | 5 | 0 | 0 |
agent.mode | confirm | 未设置 | yolo | yolo |
environment.env.PAGER | cat | less | 未设置,即 UNSET | less |
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_agentget_environmentget_model
配置与辅助逻辑:
_multiline_prompt收多行任务。builtin_config_dir、get_config_from_spec定位并解析配置。configure_if_first_time处理首次运行设置。UNSET、recursive_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()时重新读取环境变量。 - 顶层
minisweagentimport 还会创建全局配置目录并加载.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 | 作用 |
|---|---|---|---|
| 56 | model_name | -m/--model | 覆盖模型名 |
| 57 | model_class | --model-class | 选模型实现短名或完整路径 |
| 58 | agent_class | --agent-class | 选 Agent 实现 |
| 59 | environment_class | --environment-class | 选 Environment 实现 |
| 60 | task | -t/--task | 问题描述 |
| 61 | yolo | -y/--yolo | 取消模型命令确认 |
| 62 | cost_limit | -l/--cost-limit | 成本限制;0 表示关闭 |
| 63 | config_spec | -c/--config | 可重复的配置层 |
| 64 | output | -o/--output | trajectory 文件 |
| 65 | exit_immediately | --exit-immediately | Agent 提交时不再询问新任务 |
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 UNSET:None和空字符串都视为没提供,所以空 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)
精确装配顺序:
- Model 工厂根据 model name/class 创建模型。
- Environment 工厂默认选择 LocalEnvironment。
- Agent 工厂默认选择 InteractiveAgent,并注入前两个实例。
- 调用统一 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=...) 会:
- 解析 model name。
- 深拷贝 config,避免
pop()改坏调用者对象。 - 解析
model_class短名或完整路径。 - 写入最终 model name。
- 用
model_class(**config)创建实例。
Environment 工厂
get_environment(config, default_type="local") 会:
- 深拷贝 config。
- 弹出
environment_class,缺失时用local。 - 解析短名或完整路径。
- 用剩余配置构造 Environment。
Agent 工厂
get_agent(model, env, config, default_type="interactive") 会:
- 深拷贝 config。
- 弹出
agent_class,缺失时用interactive。 - 解析短名或完整路径。
- 调用
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
Console、Rule:终端输出和分隔线。 AgentConfig、DefaultAgent:父配置与父类。_multiline_prompt、prompt_session:用户输入。LimitsExceeded、Submitted、TimeExceeded、UserInterruption:流程信号。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:近似 JSswitch。
如果输入 /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,并显式转换为
int、float。 - 第 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 内:
- 第 130 行判断是否需要确认整批 commands。
- 第 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"
时序:
- Typer 把字符串、布尔 flag 和 list options 变成 Python 参数。
configure_if_first_time()确保模型基础设置存在。mini.yaml变成完整 dict。agent.cost_limit=1变成局部嵌套 dict。- CLI overlay 写入 task、
mode=yolo、confirm_exit=False。 recursive_merge得到最终配置。- Model 工厂选择供应商适配器并创建实例。
- Environment 工厂默认创建 LocalEnvironment。
- Agent 工厂默认创建 InteractiveAgent,并注入前两个对象。
run("Fix issue")生成 system/user 初始消息。query()调模型;yolo mode 不改变 query。execute_actions()因 yolo 跳过确认,直接让 LocalEnvironment 执行。- Environment 抛 Submitted 时,因为
confirm_exit=False,立即结束。 - 父类 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 不被修改。
None和UNSET跳过。- 深层只有 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 用例因为当前虚拟环境没有安装 mini、mini-swe-agent、mini-extra 可执行入口而失败;这是本地安装形态问题,不是配置合并或 Typer app 行为失败,也没有触发 API 请求。
14. 测试缺口与容易误读之处
- 没有完整测试五个 config 搜索位置的优先级和
MSWEA_CONFIG_DIR。 - 没有覆盖
.yml、.YAML的 suffix 替换语义。 - 没有验证空 YAML、list 或 scalar 顶层会得到清晰错误。
- 没有把
mini.yaml三段配置统一送进目标配置 schema 做一次启动前验证。 - 10000 字符边界没有直接测试。
task or UNSET把空字符串当未设置的行为缺少明确契约测试。- whitelist 的空正则、非法正则和复杂正则性能缺少测试。
- prompt helper 的递归深度没有测试。
cost_last_confirmed和_add_observation_messages没有调用路径测试。- 动态 import 目标模块内部依赖失败时,工厂可能把真实 ImportError 包装成“Unknown type”,缺少诊断测试。
15. 可维护性、性能与安全审查
可维护性
- 配置错误发现较晚。 YAML 先作为裸 dict 合并,直到三个工厂构造 Pydantic config 才校验。增加启动前 schema 验证会更早报错;风险是插件配置和不同实现允许的字段不完全相同。
- 未知字段可能静默失效。 默认 Pydantic 行为可能忽略额外字段,拼错 option 或把 Interactive 字段交给 DefaultAgent 时不一定立即失败。改成禁止 extra 会提高正确性,但可能破坏现有宽松配置。
- 大 prompt 同时绑定多个协议。 修改提交标记、tool 名称或 shell 语义时,需要同步 Model parser、Environment 和测试。
- 两个疑似残留成员。
cost_last_confirmed与_add_observation_messages当前没有仓库内调用。删除能减小认知负担,但半私有方法可能已被外部子类使用。 - 递归 prompt 可改循环。 循环能消除极端递归深度风险;重构时要保留
/m、/h、重复 mode 和提交确认的精确返回语义。
性能
- 几份小 YAML 和一次深合并几乎不是瓶颈。
- observation 模板会反复构造 Jinja Template;长任务可考虑缓存编译结果。
- 10000 字符截断只减少模型上下文,不减少 Environment 捕获完整输出的内存。
_should_ask_confirmation()对每个 action 和每条 whitelist regex 做匹配;通常列表很小,收益远低于 Model/API 延迟。- 父类每轮保存完整且不断增长的 trajectory,长任务累计写入量可能接近 O(n²)。
- action 当前顺序执行;并行化会改变共享工作目录和副作用顺序,不是无风险优化。
安全
- LocalEnvironment 是权限边界。 它用当前用户权限运行 shell。confirm 只是人工闸门,不是隔离。
- yolo 移除主要确认闸门。 面对不可信仓库或 prompt injection,应优先换 Docker/Bubblewrap 等 Environment。
- human mode 也直接执行。 用户命令不经过模型,但仍有当前 Environment 的全部权限。
- whitelist 必须谨慎。 空正则匹配所有命令,过宽正则可能让危险复合命令免确认;
re.match只约束字符串开头,不理解 shell AST。 - 配置是可信代码边界。 它可以指定动态 class path;Jinja 也不是面向不可信模板的 sandbox。
safe_load只保护 YAML 反序列化。 它不能阻止危险 class、危险 shell、密钥泄漏或 prompt injection。- 当前目录配置可遮蔽短名。 敏感运行应使用明确绝对路径,而不是依赖
-c mini搜索。 - 不要把密钥写进 CLI override。 命令可能出现在 shell history、进程信息和保存的配置/trajectory 中。
- Environment 模板变量可能含宿主环境变量。 不可信 Jinja 模板可能引用并把敏感值发送给模型。
改进风险速查
| 改进 | 收益 | 兼容风险 |
|---|---|---|
exists() 改 is_file() | 更早拒绝目录 | 低 |
| YAML 顶层强制 dict | 错误更清晰 | 依赖非常规 YAML 的调用者会失败 |
| Pydantic 禁止未知字段 | 抓拼写错误 | 插件和共享 config 的额外字段可能失效 |
| 启动时预编译 whitelist regex | 更早发现坏正则 | 旧配置会更早失败 |
| 禁止空 whitelist regex | 避免意外全放行 | 可能破坏有意的全白名单设置 |
| prompt 递归改循环 | 消除栈增长 | 要精确保持 slash command 行为 |
| 默认隔离 Environment | 显著提高安全性 | 安装、性能、文件挂载和平台体验改变 |
| 配置/trajectory 统一脱敏 | 降低密钥泄漏 | 降低完整复现和供应商排错能力 |
16. 无 API、无 shell 小练习
练习脚本已作为本文附件提供。
它同时练四件事:
- 用
get_config_from_spec("mini")读取内置 YAML。 - 用点号 spec 把 mode 覆盖为 yolo,并把
confirm_exit解析成真正的 False。 - 用三个工厂创建 DeterministicModel、MemoryEnvironment 和默认 InteractiveAgent。
- 让 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让它直接结束。
动手改三处:
- 删除
get_config_from_spec("agent.mode=yolo"),观察程序为何开始等待确认;不要在无人值守环境中继续卡住,看到 prompt 后按 Ctrl+C。 - 把
agent.confirm_exit=false改成agent.confirm_exit=true,观察完成时为何多一道选择。 - 把 Environment class 改成不存在的路径,阅读工厂产生的错误消息并定位解析失败发生在哪一层。
17. 三道检查题(请先回答,不要查答案)
mini -c mini.yaml -c agent.cost_limit=5 --cost-limit 0最终的 cost limit 是多少;为什么这里必须用is not None和UNSET,不能用普通的or?run/mini.py为什么必须先创建 Model 和 Environment,再创建 Agent;没有写agent_class时,哪个参数让工厂最终选择 InteractiveAgent?- 在
confirmmode 中,一批命令只有一条未命中 whitelist 时会发生什么;用户输入/u后,当前 batch、Agent mode 和下一轮分别怎样变化?