跳到主要内容

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 固定成哨兵且关了回显,现在"命令结束"就等价于:在输出里 expectSHELLPS1PREFIXpexpectexpect(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\ncmd22 条普通换行是分隔
cmd1\\\n asdf1 条换行被转义,是续行
cmd1<<EOF\na\nb\nEOF1 条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。几个精到的细节:

  • 前后缀都要,且分两半。EXITCODESTARTEXITCODEEND 夹住,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.pyBashSession._run_normal
交互式命令src/swerex/runtime/local.pyBashSession._run_interactive
中断/打断src/swerex/runtime/local.pyBashSession.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.pyBashSession._UNIQUE_STRING_ps1
相关异常src/swerex/exceptions.pyNoExitCodeErrorCommandTimeoutErrorBashIncorrectSyntaxError