读完本章,你应能说明:run() 请求的一次环境操作,怎样进入远端沙箱、返回结果,并让调用者继续执行?
本节局部数据流
沿用工作区 /workspace/demo。此时 sb 已经可用;我们从准备 agent 用户的一次命令进入,先读完命令的正常路径,再补文件操作和环境的来去。
查看这一步在完整任务中的位置
Pre-4 已创建环境并安装 CLI。Action 4 的 Harness 使用这个环境;本章展开它调用的一项能力。环境操作也会出现在题目准备、输出收集等位置。
本章阅读语境
从 Pre-4、Harness 或长命令执行代码中的一次环境操作下钻;这些能力会被多处反复调用。
- 当前主线
Sandbox接口 →E2BSandbox后端- 本章展开
- 命令和文件 API 如何落实到后端、返回结果与错误,按需回看环境创建和回收。
- 读完返回
- 回到调用 sb.exec/read_file/write_file 的位置,继续原来的任务;本章不是启动之后单独发生的一阶段。
把图接到真实调用
BaseHarness.run() 先把 sb 和工作区交给用户准备函数。下面摘出实际调用及紧接着的代码;完整启动流程在 Action 4。
await _sandbox.ensure_agent_user(sb, workdir)
ctx = HarnessContext(
workdir=workdir,
session_id=session_id,
adapter_url=adapter_url,
)
await self.write_config(sb, ctx)
return await self.launch_and_wait(sb, ctx, prompt, time_budget_sec)
这里的第一个 await 完成后,才会创建 ctx。因此本章的返回位置是 ctx = HarnessContext(...),而不是另启动一个 Agent。
查看源码出处与行号
1.先认清交出去的对象,再形成命令
| 此时的数据 | 下一步交给谁 | 最小理解 |
|---|---|---|
sb | ensure_agent_user(),随后调用其 exec() | 指向已经创建的同一个执行环境;后续配置和 CLI 也使用它 |
workdir = "/workspace/demo" | ensure_agent_user() | 远端任务目录;本次要把这个目录的所有权交给 agent 用户 |
sb 在不同位置只是同一对象的参数名。本例中,它指向一个 E2BSandbox 包装对象。本章涉及的组成只有这些:
sandbox_id:远端执行环境的标识。 用来识别这个沙箱,不是任务的instance_id,也不是 Adapter 会话的session_id。_sb:E2B SDK 操作句柄。 后面用它的commands.run发命令、用files读写文件。exec()、write_file()、read_file():调用方使用的方法。 它们把 SDK 操作封装成稳定的输入输出。
Sandbox 是这些能力的 Protocol 约定,不是又一层运行实例。E2BSandbox 提供相应方法,调用方就可以按这个约定使用它,无须继承 Sandbox。
查看源码出处与行号
- 公共能力约定:slime/agent/sandbox.py:23
- 包装对象与字段:slime/agent/sandbox.py:160
- 句柄与环境标识赋值:slime/agent/sandbox.py:294
ensure_agent_user() 把本次要求变成一条 shell 命令:
async def ensure_agent_user(sb: Sandbox, workdir: str) -> None:
"""Create the unprivileged 'agent' user that owns workdir + can git diff."""
await sb.exec(
f"id agent >/dev/null 2>&1 || useradd -m -s /bin/bash agent && "
f"chown -R agent:agent /home/agent {workdir} && "
f"git config --system --add safe.directory '*' && id agent",
user="root",
check=True,
timeout=60,
)
本例请求以 root 身份执行,操作超时为 60 秒。命令检查或创建 agent 用户,设置用户目录及工作区的所有权,并添加 Git 安全目录配置。
调用没有传 env,因此使用默认的 None;没有传 idempotent,因此使用默认的 True。这两个参数下一步会进入后端。
查看源码出处与行号
- 调用处:slime/agent/harness/common.py:97
- 完整用户准备函数:slime/agent/sandbox.py:390
- exec 参数默认值:slime/agent/sandbox.py:309
2.同一条命令怎样进入远端环境
调用来到 E2BSandbox.exec()。先只看发送和正常返回这一段:
res = await self._rpc_retry(
f"exec({cmd[:60]!r})",
lambda: self._sb.commands.run(
cmd,
user=user,
envs=env,
timeout=timeout,
on_stdout=lambda s: None,
on_stderr=lambda s: None,
),
idempotent=idempotent,
)
return res.exit_code, res.stdout or "", res.stderr or ""
这里有两个连续交接:
exec()把一次 SDK 调用交给_rpc_retry()执行。传入的是lambda,需要重试时才会重新发起这一调用。- SDK 句柄的
commands.run()把命令交给远端环境执行。cmd、user和timeout继续传下去;Slime 的env在 SDK 参数中改名为envs。
on_stdout、on_stderr 在这里不做实时处理;成功后,从 SDK 结果对象读取退出状态和输出,组成:
(exit_code, stdout, stderr)
假设 SDK 报告成功,并返回下面的教学示意值:
{
"exit_code": 0,
"stdout": "uid=1000(agent) gid=1000(agent) groups=1000(agent)\n",
"stderr": ""
}
那么 exec() 返回对应的三元组。这里的用户编号和输出仅用于说明结构,没有真实执行远端命令;SDK 的空输出会由 or "" 统一成空字符串。完整教学输入与响应见示例数据。
查看源码出处与行号
- 发送与正常返回:slime/agent/sandbox.py:320
- SDK 句柄来源:slime/agent/sandbox.py:298
3.结果回来了,调用者留下什么
再看用户准备函数的调用形式:它只是 await sb.exec(...),没有把返回三元组赋给变量。
因此,这次正常返回有两类成果:
| 留下什么 | 谁接着使用 |
|---|---|
ensure_agent_user() 正常返回 None | run() 继续构造 HarnessContext |
| 环境中的用户、目录所有权与 Git 配置 | 后续在同一 sb 中写配置、启动 CLI 的操作 |
返回 None 不等于没有做事。 本次主要成果是远端环境状态改变;三元组是底层接口的结果,用户准备函数在正常路径中不需要保存它。
随后 run() 调用 write_config(sb, ctx),会再次使用同一个环境接口。Claude 配置的具体内容已经在 Action 4 展开,这里读到“同一 sb 被继续使用”即可。
查看源码出处与行号
- 用户准备完整函数:slime/agent/sandbox.py:390
- 返回后继续:slime/agent/harness/common.py:97
- 后续配置仍使用 sb:slime/agent/harness/claude_code.py:44
4.失败时,先分清两个问题
正常路径已经返回 run()。这一节先记住两个开关各自负责什么,具体分支可以在第二遍展开。
| 开关 | 回答的问题 | 本例的含义 |
|---|---|---|
check | SDK 报告命令失败时,怎样交给调用者? | SDK 抛出 CommandExitException 时,check=True 把它转成 RuntimeError,停止这次正常准备流程 |
idempotent | 传输中断后,是否允许重发操作? | 本例沿用 True;还需要异常被判定为瞬时错误,才会重试 |
允许重发是调用者的提示,不会自动证明命令没有重复副作用。
第二遍:展开命令异常、重放条件与退避代码
命令失败怎样交给调用方: SDK 抛出 CommandExitException 时,check 决定返回形式。
except CommandExitException as e:
if check:
raise RuntimeError(
f"e2b exec failed (exit={e.exit_code}): {cmd[:120]}\n{(e.stderr or '')[:400]}"
) from None
return e.exit_code, e.stdout or "", e.stderr or ""
check=False:把这次失败的退出状态、标准输出和错误输出交还调用者,由调用者判断。check=True:转成RuntimeError抛出。本例用户准备使用这个选项,失败时不会正常进入后面的ctx构造。
这个分支处理的是指定的 SDK 异常;代码没有另外对所有正常返回的 res.exit_code 做一次检查。
传输失败能不能再发: _rpc_retry() 同时检查异常种类与重放提示。下面摘出判断片段,后续退避代码省略。
for attempt in range(self.rpc_retries):
try:
return await coro_factory()
except Exception as e:
if not self._is_transient_rpc_error(e):
raise
if not idempotent:
raise
只有被识别为瞬时错误、且 idempotent=True,才进入后面的重试。默认最多尝试 6 次,包含第一次;重试前尝试关闭旧连接池,使下次请求重新连接,再等待带随机抖动的退避时间。其他异常、禁止重放或次数耗尽,继续向外抛出。
check 管命令失败的表达方式,idempotent 管传输失败后是否允许重放,它们不是同一个开关。后者是调用方的提示,不会自动证明命令没有重复副作用:本例的 Git --add 再次执行时仍会追加配置。响应中断时,远端命令也可能已经执行过。
查看源码出处与行号
- 命令异常:slime/agent/sandbox.py:335
- 默认重试次数:slime/agent/sandbox.py:168
- 瞬时异常识别:slime/agent/sandbox.py:211
- 重试与退避:slime/agent/sandbox.py:237
- 本例重复Git追加:slime/agent/sandbox.py:394
5.同一个环境,文件操作怎样使用
命令路径读完,再认识另外两项能力。下面是 Pre-4 准备题目时的真实调用片段;它发生在进入当前 run() 之前,用来说明同一个接口还怎样被使用。
await sb.write_file(
f"{workdir}/PROBLEM_STATEMENT.md",
md.get("problem_statement") or "",
user="agent",
)
workdir 仍可取 /workspace/demo。调用把题目文本写到远端 PROBLEM_STATEMENT.md,供稍后的 CLI 阅读;成功返回 None,成果是文件本身。
| 方法 | 输入应怎样理解 | 成功结果与需要注意的边界 |
|---|---|---|
write_file(path, content) | path 是远端路径;content 为文本、字节或宿主机的 Path 对象 | 写入成功返回 None;普通字符串始终是内容,只有 Path 对象表示读取宿主文件再上传 |
read_file(path) | 从远端路径读取文本 | 返回字符串;最终向 read_file() 抛出普通异常时会返回 "",因此空字符串也可能表示读取失败 |
读取实现很短,但它决定了调用方能否分辨失败:
async def read_file(self, sandbox_path: str, *, user: str = "root") -> str:
try:
return await self._rpc_retry(
f"read_file({sandbox_path})",
lambda: self._sb.files.read(sandbox_path, user=user),
)
except Exception:
return ""
这里捕获的是普通 Exception,不能概括成吞掉一切取消与失败。读取结果为空时,调用者如果需要区分“空文件”和“读取失败”,还需要其他证据。
为什么上传文件时,每次重试都重新打开它?
write_file() 收到 Path 后,在交给重试器的协程内部打开宿主文件;收到 bytes 时,也在每次调用里重新创建 BytesIO。这样再次发送时获得新的读取流,不会直接复用上次已经读过的流位置。最终上传仍失败时,异常继续抛出。
查看源码出处与行号
6.补齐这个对象的来去,再返回当前任务
到这里已经能解释一次操作。最后只补足 sb 的来源和回收责任:
| 时点 | 谁负责 | 留下什么 |
|---|---|---|
| 进入工作环境 | boot_agent_sandbox() 调用 E2BSandbox.__aenter__() | 通过 SDK 创建远端环境,保存 _sb 和 sandbox_id;安装 CLI 后交出 sb |
| 使用环境 | 工作区准备、Harness 和结果收集代码 | 反复调用这个对象的命令、文件能力 |
| 离开工作环境 | 外层上下文的 finally 调用 __aexit__() | 尝试调用 SDK 的 kill() 回收环境 |
构造 E2BSandbox(image) 本身只保存配置,创建发生在 __aenter__()。image 通过配置的 metadata 键交给 SDK,具体镜像路由由外部环境服务处理。
回收中的普通异常只记 warning,没有在这里重试 kill()。因此“已进入回收函数”和“已确认远端销毁”仍是两回事。
查看源码出处与行号
出口:接回 Action 4,再下钻 Action 6
现在可以回到开头那个 await:用户准备结束,run() 继续建立上下文、写配置,并组织 CLI 启动。
之后 run_agent() 也会先用 sb.exec() 准备输出目录,再把长命令交给 exec_and_wait()。到这里,动作 5 已解释了底层环境接口;启动脚本、后台进程、完成标记与等待预算,接着在动作 6 读。
两种返回合同也要分清:本章 exec() 返回三元组;exec_and_wait() 返回 (exit_code, output),run_agent() 再向上返回整数状态。等待预算耗尽返回 -1 本身不杀掉 CLI,环境回收仍由外层上下文负责。
查看源码出处与行号
- run_agent 调用与整数返回:slime/agent/harness/common.py:107
- 长命令入口和返回:slime/agent/sandbox.py:82
- 预算状态:slime/agent/sandbox.py:62
- 退出环境前收集结果:examples/coding_agent_rl/generate.py:203
回到局部图,检查是否能独立解释
sb、sb._sb和远端环境分别是什么?谁实际执行了 shell 命令?- 为什么
exec()返回三元组,而用户准备函数返回None,下一步仍可以继续? check=True与idempotent=True各管什么?为什么读取到""不能直接断定文件为空?- 如果要换一种沙箱后端,哪些命令/文件合同需要保留?长命令的等待逻辑位于哪一层?