bash 输出解析内核(最硬核的一章)
这是 SWE-ReX 真正难、也最值得借鉴的地方。问题听起来简单:"在一个 shell 里跑了条命令,怎么知道它跑完了、输出是哪些、退出码多少?" 真去做你会发现裸 shell 什么都不告诉你。本章拆解
BashSession._run_normal怎么解决它(runtime/local.py:274)。
1. 要解决的小问题
你往一个长活 bash 的 stdin 写了 pytest && echo done,然后开始读它的输出流。问题来了:
- 你读到一坨字符。这坨到哪算这条命令 的输出结束? 流不会给你"命令结束"的信号。
- 命令的退出码(
$?)在哪?bash 不会自动打印它。 - 如果命令是
ipython(不退出),你expect提示符会永远等不到。
SWE-ReX 的整套机制就是为了把这三件事变得可靠。
2. 核心直觉:用哨兵把"边界"画进输出流
关键想法一句话:既然 shell 不告诉我边界,我就自己往流里塞一个独一无二的记号,然后 expect 那个记号。
两个哨兵贯穿全章:
| 哨兵 | 值 | 作用 |
|---|---|---|
| PS1 提示符 | "SHELLPS1PREFIX"(runtime/local.py:138) | bash 每次"回到提示符"都会打印它 = 命令跑完了 |
| 唯一串 | "UNIQUESTRING29234"(runtime/local.py:130) | bashlex 失败时的兜底终止符 |
因为 01 章里 start 时已经把 PS1 固定成哨兵且关了回显,现在"命令结束"就等价于:在输出里 expect 到 SHELLPS1PREFIX。pexpect 的 expect(x) 会一直读到看见 x,并把 x 之前的所有内容放进 .before——那就是命令的输出。
# 示意,非源码 —— 哨兵法的最小内核
shell.sendline("pytest && echo done")
shell.expect("SHELLPS1PREFIX") # 一路读到下一个提示符
output = shell.before # 提示符之前 = 这条命令的全部输出
3. 三步走:_run_normal 拆 解
_run_normal 注释把自己分成三步(runtime/local.py:276-282):① 校验语法 → ② 执行 → ③ 取退出码。
第 ① 步:先做语法检查
在送进 shell 前,先用一个子 bash 的 -n(只解析不执行)模式检查语法(_check_bash_command,runtime/local.py:101-115):
# 示意,非源码 —— 语法预检的核心
cmd = f"/usr/bin/env bash -n << 'SOUNIQUEEOF'\n{command}\nSOUNIQUEEOF"
result = subprocess.run(cmd, shell=True, capture_output=True)
if result.returncode != 0:
raise BashIncorrectSyntaxError(...) # 语法错,提前报错,别污染会话
为什么重要? 如果一条语法错误的命令直接送进长活 shell,可能让 shell 卡在 PS2 续行提示符等待更多输入,把整个会话搞乱。提前用一次性子进程拦掉,会话才稳。
第 ② 步:执行——但要先把多条命令拼成一条
这里有个不显然的坑。如果用户的 command 是多行:
cmd1
cmd2
你不能原样 sendline——因为 bash 会对每条命令各打印一个 PS1,于是你 expect 到的是第一个 PS1,把 cmd2 的执行甩在后面。解决办法:把多条命令用 ; 拼成一条,这样只有最后才回到一个 PS1。
但"把多行 split 成多条命令"本身不平凡:转义换行 \ 是续行(还是一条)、heredoc(<<EOF ... EOF)中间的换行 不算分隔。SWE-ReX 用 bashlex(一个 bash 语法解析库)来正确切分(_split_bash_command,runtime/local.py:59-93)。它解析出 AST,对每个顶层命令算出在原串里的字符区间再切出来。函数 docstring 给了三个例子(runtime/local.py:68-72):
| 输入 | 切成几条 | 为什么 |
|---|---|---|
cmd1\ncmd2 | 2 条 | 普通换行是分隔 |
cmd1\\\n asdf | 1 条 | 换行被转义,是续行 |
cmd1<<EOF\na\nb\nEOF | 1 条 | heredoc 内的换行不算分隔 |
bashlex 不靠谱怎么办?兜底机制。 作者直白吐槽 "Bashlex is very buggy"(runtime/local.py:299),会抛 ParsingError / NotImplementedError / TypeError 等五花八门的错。所以有 try/except 兜底(runtime/local.py:296-305):
# 示意,非源码 —— bashlex 失败时的兜底
try:
individual_commands = _split_bash_command(action.command)
action.command = " ; ".join(individual_commands) # 正常路径:拼成一条
except Exception:
# bashlex 挂了:原样发,但在末尾追一个"保存退出码 + echo 唯一串"的尾巴
action.command += "\n TMPEXITCODE=$? ; sleep 0.1; echo -n 'UNIQUESTRING29234' ; (exit $TMPEXITCODE)"
fallback_terminator = True
兜底路径不 expect PS1,改 expect 那个唯一串(runtime/local.py:307-310)——因为多 PS1 的问题没解决,只能换个能精确定位的终止符。注释自评这"也挺脆"(runtime/local.py:295),所以不作为默认。
执行后 expect(PS1 或唯一串或用户的 expect 串),拿到 .before 作输出,超时则抛 CommandTimeoutError(runtime/local.py:311-317)。
第 ③ 步:抠退出码——再 echo 一个带前后缀的探针
命令跑完了,$? 是退出码,但它在 shell 变量里,没在输出流里。怎么拿?再 echo 一次,用前后缀把数字框起来,然后正则抠。
# 示意,非源码 —— 退出码探针(_run_normal 第 3 步)
shell.sendline("\necho EXITCODESTART$?EXITCODEEND")
shell.expect("EXITCODEEND") # 等到后缀出现
raw = shell.before # 形如 "...EXITCODESTART0"
m = re.findall(r"EXITCODESTART([0-9]+)", raw) # 正则抠出数字
if len(m) != 1:
raise NoExitCodeError(...) # 抠不到唯一一个 = 失败
exit_code = int(m[0])
真实实现见 runtime/local.py:323-345。几个精到的细节:
- 前后缀都要,且分两半。 用
EXITCODESTART和EXITCODEEND夹住,expect后缀保证数字已经完整打印出来;再把EXITCODESTART之前的内容(那其实是真实命令的尾部输出)拼回output(runtime/local.py:337)。这样"取退出码"这步本身不会吃掉命令输出。 - 必须
findall恰好一个。 如果匹配到 0 个或多个,说明输出流里混进了别的东西,判为失败抛NoExitCodeError(runtime/local.py:334-336)。宁可报错也不返回错的退出码。 - 取完还要再吞一个 PS1。 echo 命令自己也会产生一个新提示符,得
expect掉它(runtime/local.py:340-344),否则残留会污染下一条命令。 check策略决定异常处理。"ignore"直接跳过整个第 3 步、退出码留None(runtime/local.py:320-321);"silent"抠失败就吞掉异常、退出码设None;"raise"才把异常抛出去(runtime/local.py:346-350)。
4. 交互式命令:放弃退出码,换一种结束判定
ipython / gdb 这类程序进去就不退出,上面那套"回到 PS1 + 取退出码"完全不适用。_run_interactive 走另一条路(runtime/local.py:245-272):
- 不取退出码,固定返回
exit_code=0(runtime/local.py:272)。 - 结束判定靠
expect。 你得在BashAction.expect里告诉它"看到什么算这步结束"(比如 ipython 的In [.*]:提示符),它把这些串和 PS1 一起等(runtime/local.py:251-254)。 is_interactive_quit处理"退出交互程序"。 当你发的是quit/exit这类要离开 ipython 的命令时,它做一套精细的回显与哨兵操作把控制权干净地交还给外层 bash(runtime/local.py:259-266),这样后续普通命令又能正常跑。
这就是 01 章里 BashAction 那两个 flag(is_interactive_command / is_interactive_quit)的用武之地。
5. 别忘了的清洗:去掉 ANSI 控制字符
终端输出里满是颜色码、光标移动这类 ANSI 转义序列。每次取 .before 都先过 _strip_control_chars(runtime/local.py:96-98):用正则 \x1B[@-_][0-?]*[ -/]*[@-~] 删掉 ANSI 转义,并把 \r\n 归一成 \n。否则返回给 agent 的输出会混入一堆 \x1b[0m 之类的垃圾。
6. 把整条路径串起来
一条普通命令从进到出,完整流向(从上到下):
action.command
│
▼
① _check_bash_command ── bash -n 子进程预检语法 ──▶ 错则抛 BashIncorrectSyntaxError
│ ok
▼
② _split_bash_command ── bashlex 切分 + " ; ".join ──┐ (失败则走唯一串兜底)
│ │
▼ │
sendline(command) ──▶ expect(PS1 或唯一串) ──▶ before = 命令输出(_strip_control_chars)
│
▼
③ echo EXITCODESTART$?EXITCODEEND ──▶ expect(后缀) ──▶ 正则抠数字 ──▶ expect(残余 PS1)
│
▼
BashObservation(output, exit_code, expect_string)
这套"哨兵 + 探针"的组合务实但脆,作者自己反复标注(runtime/local.py:295、:299)。但它换来的是:一个纯文本 shell 流,被可靠地切成了"输出 + 退出码"的结构化结果,且不依赖任何特殊 shell 协议——这正是它能跑在任意环境上的原因。
7. 代码地图
| 主题 | 文件路径 | 关键符号 |
|---|---|---|
| 普通命令三步走 | src/swerex/runtime/local.py | BashSession._run_normal |
| 交互式命令 | src/swerex/runtime/local.py | BashSession._run_interactive |
| 中断/打断 | src/swerex/runtime/local.py | BashSession.interrupt |
| 命令切分(bashlex) | src/swerex/runtime/local.py | _split_bash_command |
| 语法预检 | src/swerex/runtime/local.py | _check_bash_command |
| ANSI 清洗 | src/swerex/runtime/local.py | _strip_control_chars |
| 两个哨兵常量 | src/swerex/runtime/local.py | BashSession._UNIQUE_STRING、_ps1 |
| 相关异常 | src/swerex/exceptions.py | NoExitCodeError、CommandTimeoutError、BashIncorrectSyntaxError |