fix(exec): run_python script_path 宿主预检+前缀容错+code 落盘回退(bump 0.58.22)

失败聚集 run_python/exit「can't open file」15 次/7 天/13 task,DB 逐条核对三形态:
- 没写就跑(13/15):Docker 执行器补宿主侧 is_file 预检,返带纠偏提示的
  [Error] script_path not found,与 host 执行器对齐,不再进容器撞裸 stderr
- 双重拼接:fs 工具输出渲 user_root 相对路径而解析按 workdir 相对(展示/解析
  不对称),模型回传即踩坑;workdir 相对不存在时按 user_root 相对再试
- code+script_path 同给:文件不存在时 code 落盘到 script_path 再执行,
  不再扔掉完好的 code(schema 描述同步注明)

_container_script_path 重写为 _prepare_script(前台+background 共用,code
回写在宿主侧故加 user_root 越界防护);tools/run_python.py host 路径同语义。
测试 +8;诊断脚本 scripts/diag_run_python_noent{,2}.py 留档。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-07-13 16:41:43 +08:00
parent 859c02cf65
commit 732420a3b7
8 changed files with 366 additions and 13 deletions

View File

@ -23,6 +23,7 @@
### 2026-07 ### 2026-07
- **07-13 / 0.58.22**:**run_python script_path 宿主预检 + 路径容错 + code 落盘回退**(失败聚集面板 `run_python/exit`「can't open file」15 次/7 天/13 task,逐条 DB 核对出三种形态):① 形态 1(13/15,没写就跑):模型调 `script_path` 但前文从未 write 过该文件(如 90c8bba9 #29),Docker 执行器原本不预检直接 `docker exec python <path>`,模型拿裸 stderr 自愈慢——现宿主侧 `is_file()` 预检(bind mount 免进容器),返带纠偏提示的 `[Error] script_path not found`(与 host 执行器行为对齐)。② 形态 2(双重拼接,0d7dd9d9 #74):根因是**展示/解析不对称**——fs 工具输出渲染 user_root 相对路径(`tools/base.py::_display` → `吴中伟科技奖/scripts/x.py`),模型原样回传,而 `_container_script_path` 按 workdir 相对拼 → `/workspace/<dir>/<dir>/…`;现 workdir 相对不存在时按 user_root 相对再试一次。③ 形态 3(a395ee0c #134,code+script_path 同给):原本 script_path 优先、文件不存在报错,整段完好 code 被扔掉;现文件不存在且 code 非空 → code 落盘到 script_path 再执行(正合工具描述「先写后跑」引导,schema 描述同步注明)。`DockerExecutor._container_script_path` 重写为 `_prepare_script`(前台+background 两路共用,code 回写在宿主侧故加 user_root 越界防护);`tools/run_python.py` host 路径同语义(`_prepare_script` helper,execute/_execute_background 共用)。`tests/test_executor_docker.py` +4 用例(not found 不起 docker / 前缀容错 / code 落盘 / 越界拒),`tests/test_run_python_script_path.py` +4(host 侧同形态 + 已存在文件不被 code 覆盖)。诊断脚本 `scripts/diag_run_python_noent{,2}.py` 留档。
- **07-13 / 0.58.21**:**run 级错误前端持久提示 + 进失败聚集面板 + provider 级致命错误即时告警**(用户报 task 2a1bc25d "ppt3":Zai 余额不足连挂 3 次续跑,前端零提示、admin 零感知):错误其实有 emit(SSE error 事件→前端错误卡),但 fetchSse 收尾 `loadMessages()``renderMessages``innerHTML=""` 整屏重建,错误不是持久化消息 → 卡瞬间被冲掉;历史打开更没有(run_error 唯一出口是任务列表红点 hover title)。admin 侧 toolfail 巡检只扫 tool 消息 + tool_malformed,LLM 请求层直接抛的 run 级错误(整轮无 tool 消息)全盲。三改:① chat.js 新增 `renderPersistedRunError()`(挂在 renderMessages 两分支末尾,所有重渲路径统一走):meta.run_status=error 且无 live run → 消息流末尾补「上次运行出错」持久卡(含重试引导),刷新/切任务都在,下次 run 起跑后端清 run_error 自然消失;selectTask 本就先赋 taskMeta 再 renderMessages,时序天然成立。② run 错误留痕:`_run_agent_bg` except 里加 `record_run_error`(usage_events kind=run_error,units={err},cost 0——run_error 列只留最后一次,此行才是完整数据源);`core/toolfail.py` 加第三段扫描聚成 kind=run / tool=(run) 的 cluster,admin 面板/巡检邮件零改动自动带上。③ provider 级致命错误(余额不足/配额/认证,正则关键词)即时邮件 `alert_provider_critical`:这类错误该 provider 上所有 run 全挂,等日巡检 5 次/2 task 阈值太慢;同归一化签名 6h 进程内冷却,SMTP 未配只打日志,全路径静默失败不反噬错误路径。`tests/test_toolfail_malformed.py` +3 用例(run_error 聚集 / 关键词命中+冷却 / 普通异常不告警),FakeSession 分流补第三段。 - **07-13 / 0.58.21**:**run 级错误前端持久提示 + 进失败聚集面板 + provider 级致命错误即时告警**(用户报 task 2a1bc25d "ppt3":Zai 余额不足连挂 3 次续跑,前端零提示、admin 零感知):错误其实有 emit(SSE error 事件→前端错误卡),但 fetchSse 收尾 `loadMessages()``renderMessages``innerHTML=""` 整屏重建,错误不是持久化消息 → 卡瞬间被冲掉;历史打开更没有(run_error 唯一出口是任务列表红点 hover title)。admin 侧 toolfail 巡检只扫 tool 消息 + tool_malformed,LLM 请求层直接抛的 run 级错误(整轮无 tool 消息)全盲。三改:① chat.js 新增 `renderPersistedRunError()`(挂在 renderMessages 两分支末尾,所有重渲路径统一走):meta.run_status=error 且无 live run → 消息流末尾补「上次运行出错」持久卡(含重试引导),刷新/切任务都在,下次 run 起跑后端清 run_error 自然消失;selectTask 本就先赋 taskMeta 再 renderMessages,时序天然成立。② run 错误留痕:`_run_agent_bg` except 里加 `record_run_error`(usage_events kind=run_error,units={err},cost 0——run_error 列只留最后一次,此行才是完整数据源);`core/toolfail.py` 加第三段扫描聚成 kind=run / tool=(run) 的 cluster,admin 面板/巡检邮件零改动自动带上。③ provider 级致命错误(余额不足/配额/认证,正则关键词)即时邮件 `alert_provider_critical`:这类错误该 provider 上所有 run 全挂,等日巡检 5 次/2 task 阈值太慢;同归一化签名 6h 进程内冷却,SMTP 未配只打日志,全路径静默失败不反噬错误路径。`tests/test_toolfail_malformed.py` +3 用例(run_error 聚集 / 关键词命中+冷却 / 普通异常不告警),FakeSession 分流补第三段。
- **07-13 / 0.58.20**:**非流式重试期间「思考中」指示恢复 + 停止即时生效**(用户报 716ed3be 重试期间页面无 loading、与卡死无法区分;代码已随 da289b2/0.58.19 一并入库,本条补文档):非流式一次生成可达分钟级且完成前零事件,而 `warn` 事件会让前端把当前文字段定稿关闭(chat.js `closeTextSeg`),「思考中 · Ns」占位段没人重建 → 页面静止到重试完成。① `llm_start` emit 挪进 attempt 循环体每次重发(stats 同一份):前端既有逻辑 `ensureTextSeg + setRunPhase("llm")` 自动重建占位段恢复跳秒,前端零改动。② `_nonstream_once` 阻塞调用挪后台 daemon 线程,主线程 0.5s 一拍 poll cancel,命中返 `None``_stream_llm``(None, True)` 走既有 cancelled 路径——此前点「停止」要同步等非流式调用整个返回(分钟级);弃养线程自行跑完、响应丢弃不入库不记账,与流式 cancel 丢弃已收 chunk 语义一致(provider 侧照跑照计费,litellm 同步调用不暴露连接中断,省不掉)。`tests/test_loop_malformed_retry.py` +4 用例:每 attempt 重发 llm_start 计数 / 非流式期 cancel 短路 / 真 `_nonstream_once` 在 llm.chat 阻塞 30s 时 poll 拍内返回 / 不取消行为与原同步版等价。 - **07-13 / 0.58.20**:**非流式重试期间「思考中」指示恢复 + 停止即时生效**(用户报 716ed3be 重试期间页面无 loading、与卡死无法区分;代码已随 da289b2/0.58.19 一并入库,本条补文档):非流式一次生成可达分钟级且完成前零事件,而 `warn` 事件会让前端把当前文字段定稿关闭(chat.js `closeTextSeg`),「思考中 · Ns」占位段没人重建 → 页面静止到重试完成。① `llm_start` emit 挪进 attempt 循环体每次重发(stats 同一份):前端既有逻辑 `ensureTextSeg + setRunPhase("llm")` 自动重建占位段恢复跳秒,前端零改动。② `_nonstream_once` 阻塞调用挪后台 daemon 线程,主线程 0.5s 一拍 poll cancel,命中返 `None``_stream_llm``(None, True)` 走既有 cancelled 路径——此前点「停止」要同步等非流式调用整个返回(分钟级);弃养线程自行跑完、响应丢弃不入库不记账,与流式 cancel 丢弃已收 chunk 语义一致(provider 侧照跑照计费,litellm 同步调用不暴露连接中断,省不掉)。`tests/test_loop_malformed_retry.py` +4 用例:每 attempt 重发 llm_start 计数 / 非流式期 cancel 短路 / 真 `_nonstream_once` 在 llm.chat 阻塞 30s 时 poll 拍内返回 / 不取消行为与原同步版等价。
- **07-13 / 0.58.19**:**畸形 tool_call 进 admin「工具失败聚集」+ 巡检邮件只发活跃聚集**(0.58.18 的观测盲区收尾 + 用户报巡检邮件太频):① 畸形留痕落库:`_log_malformed_args` 在 stdout 外加写 `usage_events` 一行 `kind=tool_malformed`(新 `record_malformed_tool_call`,units={tool,len,err,head,tail,tokens_in/out}),**cost_cny 恒 0**——cost 统计全 kind 合计且随任务展示,provider 抖动的浪费不算用户花销;真实 token 快照进 units 供反推浪费(token 汇总只算 kind=chat,不污染)。DB 写失败静默,绝不阻塞重试主路径。② `core/toolfail.py` 加第二段扫描(usage_events kind=tool_malformed),聚成 kind=malformed 的同构 cluster(signature=归一化 JSON 报错——行列号数字被 `_normalize` 抹平自动聚拢,sample=head…tail),admin 面板/巡检邮件零改动自动带上;聚合逻辑抽 `_add` 闭包双数据源复用。③ 巡检邮件降频:签名去重集是内存态、每次部署清零,高频部署期每次重启把 7 天窗口内早已安静的存量聚集重发一遍——发信前加 `count_24h>0` 过滤,已安静的不再重发,还在烧的重启后再提醒一次(刻意保留)。新增 `tests/test_toolfail_malformed.py` 4 用例(mock session_scope 不碰 DB:聚集/签名归一/双源并存/历史分桶);RUN.md env 巡检段 + 故障兜底 909 行同步。 - **07-13 / 0.58.19**:**畸形 tool_call 进 admin「工具失败聚集」+ 巡检邮件只发活跃聚集**(0.58.18 的观测盲区收尾 + 用户报巡检邮件太频):① 畸形留痕落库:`_log_malformed_args` 在 stdout 外加写 `usage_events` 一行 `kind=tool_malformed`(新 `record_malformed_tool_call`,units={tool,len,err,head,tail,tokens_in/out}),**cost_cny 恒 0**——cost 统计全 kind 合计且随任务展示,provider 抖动的浪费不算用户花销;真实 token 快照进 units 供反推浪费(token 汇总只算 kind=chat,不污染)。DB 写失败静默,绝不阻塞重试主路径。② `core/toolfail.py` 加第二段扫描(usage_events kind=tool_malformed),聚成 kind=malformed 的同构 cluster(signature=归一化 JSON 报错——行列号数字被 `_normalize` 抹平自动聚拢,sample=head…tail),admin 面板/巡检邮件零改动自动带上;聚合逻辑抽 `_add` 闭包双数据源复用。③ 巡检邮件降频:签名去重集是内存态、每次部署清零,高频部署期每次重启把 7 天窗口内早已安静的存量聚集重发一遍——发信前加 `count_24h>0` 过滤,已安静的不再重发,还在烧的重启后再提醒一次(刻意保留)。新增 `tests/test_toolfail_malformed.py` 4 用例(mock session_scope 不碰 DB:聚集/签名归一/双源并存/历史分桶);RUN.md env 巡检段 + 故障兜底 909 行同步。

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。 # 改版本只动这一行。
__version__ = "0.58.21" __version__ = "0.58.22"

View File

@ -204,9 +204,11 @@ class DockerExecutor(Executor):
content="[Error] bad arguments to run_python: script_path must be non-empty string", content="[Error] bad arguments to run_python: script_path must be non-empty string",
exit_code=2, exit_code=2,
) )
container_script, err = self._prepare_script(script_path, code)
if err is not None:
return ToolResult(content=err, exit_code=2)
timeout = int(args.get("timeout") or 120) timeout = int(args.get("timeout") or 120)
container = self.pool.ensure(self.user_id) container = self.pool.ensure(self.user_id)
container_script = self._container_script_path(script_path)
argv = self._docker_exec_argv( argv = self._docker_exec_argv(
container, container,
extra_env=_sandbox_env({"PYTHONIOENCODING": "utf-8"}), extra_env=_sandbox_env({"PYTHONIOENCODING": "utf-8"}),
@ -295,7 +297,10 @@ class DockerExecutor(Executor):
script_path = args.get("script_path") script_path = args.get("script_path")
code = args.get("code") code = args.get("code")
if isinstance(script_path, str) and script_path.strip(): if isinstance(script_path, str) and script_path.strip():
inner = f"python {self._container_script_path(script_path)}" container_script, err = self._prepare_script(script_path, code)
if err is not None:
return ToolResult(content=err, exit_code=2)
inner = f"python {container_script}"
display, kind = f"python {script_path.strip()}", "python" display, kind = f"python {script_path.strip()}", "python"
elif isinstance(code, str) and code.strip(): elif isinstance(code, str) and code.strip():
(d / "script.py").write_text(code, encoding="utf-8", newline="\n") (d / "script.py").write_text(code, encoding="utf-8", newline="\n")
@ -361,11 +366,47 @@ class DockerExecutor(Executor):
exit_code=0, exit_code=0,
) )
def _container_script_path(self, script_path: str) -> str: def _prepare_script(self, script_path: str, code: Any) -> tuple:
"""script_path → 容器内绝对路径,宿主侧做存在性预检。返回 (container_path, err)。
背景(0.58.22,失败聚集 run_python/exitcan't open file」15 次/7 天):原实现
不预检直接 `docker exec python <path>`,模型拿到裸 stderr 自愈慢三种形态:
- 路径带工作目录名前缀 双重拼接根因是 fs 工具输出渲染 user_root 相对路径
(tools/base.py::_display),模型原样回传,而这里按 workdir 相对解析
展示/解析不对称容错:workdir 相对不存在时按 user_root 相对再试一次
- code + script_path 同给且文件不存在 模型意图是 code 存到 script_path
再跑(工具描述本就引导先写后跑),照做,别扔掉完好的 code
- 没写就跑(主体,模型幻觉) 返回带纠偏提示的 [Error],不进容器起 python
"""
p = script_path.replace("\\", "/").strip() p = script_path.replace("\\", "/").strip()
if p.startswith("/"): if p.startswith("/"):
return p if p != "/workspace" and not p.startswith("/workspace/"):
return self.container_workdir.rstrip("/") + "/" + p.lstrip("./") # /tmp 等容器内路径,宿主不可映射 —— 跳过预检,交给容器报错
return p, None
rest = p[len("/workspace"):].lstrip("/")
container, host = "/workspace/" + rest, self.user_root / rest
else:
rel = p.lstrip("./")
container = self.container_workdir.rstrip("/") + "/" + rel
host = self.working_dir / rel
if not host.is_file() and (self.user_root / rel).is_file():
container, host = "/workspace/" + rel, self.user_root / rel
# 越界防护:下面的 code 回写发生在宿主侧,不受容器 /workspace 物理边界保护
try:
host.resolve().relative_to(self.user_root)
except (ValueError, OSError):
return "", f"[Error] script_path out of bounds: {script_path}"
if host.is_file():
return container, None
if isinstance(code, str) and code.strip():
host.parent.mkdir(parents=True, exist_ok=True)
host.write_text(code, encoding="utf-8")
return container, None
return "", (
f"[Error] script_path not found: {script_path}"
"先用 write 创建该文件再执行,或直接用 code 传源码;"
"相对路径基于 task 工作目录,不要带工作目录名前缀。"
)
# ── fs tools(read/write/edit/glob/grep)────────────────── # ── fs tools(read/write/edit/glob/grep)──────────────────

View File

@ -0,0 +1,81 @@
"""诊断 run_python 失败聚集: python: can't open file '<path>': No such file or directory
捞近 7 role=tool & name=run_python & content "can't open file" 的消息,
关联 assistant tool_call arguments, script_path 形态;并回看同 task 前文
有没有 write/run_python(code) 生成过该文件,判断是没写就跑还是路径基准错
"""
import json
import os
from pathlib import Path
env = Path(__file__).resolve().parent.parent / ".env"
for line in env.read_text(encoding="utf-8").splitlines():
if line.strip().startswith("ZCBOT_DB_URL="):
os.environ["ZCBOT_DB_URL"] = line.split("=", 1)[1].strip()
from sqlalchemy import create_engine, text # noqa: E402
engine = create_engine(os.environ["ZCBOT_DB_URL"])
with engine.connect() as conn:
rows = conn.execute(
text(
"select m.task_id, t.user_id, m.idx, m.created_at, "
" m.payload->>'tool_call_id' as tcid, "
" m.payload->>'content' as content "
"from messages m join tasks t on t.task_id = m.task_id "
"where m.created_at >= now() - interval '7 days' "
" and m.payload->>'role' = 'tool' "
" and m.payload->>'name' = 'run_python' "
" and m.payload->>'content' like '%can''t open file%' "
"order by m.created_at"
)
).fetchall()
print(f"[hits] {len(rows)} tool-result messages\n")
for task_id, user_id, idx, created, tcid, content in rows:
tid8 = str(task_id)[:8]
# 找对应 assistant 调用的 arguments
call_row = conn.execute(
text(
"select payload from messages "
"where task_id=:t and payload->>'role'='assistant' and idx < :i "
"order by idx desc limit 20"
),
{"t": task_id, "i": idx},
).fetchall()
args_repr = "?"
for (payload,) in call_row:
for tc in payload.get("tool_calls") or []:
if tc.get("id") == tcid:
args_repr = (tc.get("function") or {}).get("arguments") or "?"
break
if args_repr != "?":
break
# 提取 script_path
sp = None
try:
sp = json.loads(args_repr).get("script_path")
except Exception:
pass
# 回看同 task 前文: 有没有 write 过这个文件 (basename 匹配)
wrote = "?"
if sp:
base = sp.replace("\\", "/").split("/")[-1]
w = conn.execute(
text(
"select count(*) from messages "
"where task_id=:t and idx < :i "
" and payload->>'role'='assistant' "
" and payload::text like :pat"
),
{"t": task_id, "i": idx, "pat": f"%{base}%"},
).scalar()
wrote = f"prior-mentions={w}"
first_line = (content or "").strip().splitlines()
err_line = next((ln for ln in first_line if "can't open file" in ln), "")
print(f"[{tid8}] user={str(user_id)[:8]} idx={idx} {str(created)[:19]}")
print(f" args: {args_repr[:220]}")
print(f" err : {err_line[:220]}")
print(f" ctx : {wrote}")
print()

View File

@ -0,0 +1,61 @@
"""细看几个 can't-open-file 案例的前文: 模型到底写没写过那个 .py 文件。"""
import json
import os
import sys
from pathlib import Path
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
env = Path(__file__).resolve().parent.parent / ".env"
for line in env.read_text(encoding="utf-8").splitlines():
if line.strip().startswith("ZCBOT_DB_URL="):
os.environ["ZCBOT_DB_URL"] = line.split("=", 1)[1].strip()
from sqlalchemy import create_engine, text # noqa: E402
engine = create_engine(os.environ["ZCBOT_DB_URL"])
CASES = [
("90c8bba9", 30), # 最近一条, 相对路径形态
("0d7dd9d9", 75), # 路径重复拼接形态
("a395ee0c", 135), # args 是 code 却报 script 不存在
]
with engine.connect() as conn:
for tid8, fail_idx in CASES:
task_id = conn.execute(
text("select task_id from tasks where task_id::text like :p"),
{"p": tid8 + "%"},
).scalar()
print("=" * 100)
print(f"TASK {tid8} fail_idx={fail_idx}")
msgs = conn.execute(
text("select idx, payload from messages where task_id=:t and idx <= :i order by idx"),
{"t": task_id, "i": fail_idx + 1},
).fetchall()
for idx, p in msgs:
role = p.get("role")
if role == "assistant":
tcs = p.get("tool_calls") or []
for tc in tcs:
fn = tc.get("function") or {}
args = fn.get("arguments") or ""
print(f" #{idx} A> {fn.get('name')} {args[:180]}")
if not tcs:
c = p.get("content") or ""
if isinstance(c, list):
c = json.dumps(c, ensure_ascii=False)
print(f" #{idx} A(text)> {c[:150]}")
elif role == "tool":
c = p.get("content") or ""
if isinstance(c, list):
c = json.dumps(c, ensure_ascii=False)
name = p.get("name")
flat = " | ".join(c.strip().splitlines()[:2])
print(f" #{idx} T< [{name}] {flat[:180]}")
elif role == "user":
c = p.get("content") or ""
if isinstance(c, list):
c = json.dumps(c, ensure_ascii=False)
print(f" #{idx} U> {c[:120]}")
print()

View File

@ -361,6 +361,9 @@ class TestRunPython(unittest.TestCase):
def test_run_python_script_path_uses_existing_workspace_file(self): def test_run_python_script_path_uses_existing_workspace_file(self):
executor, pool, _ = make_executor() executor, pool, _ = make_executor()
ctx = make_ctx(executor) ctx = make_ctx(executor)
script = executor.working_dir / "scripts" / "job.py"
script.parent.mkdir(parents=True)
script.write_text("print('ok')\n", encoding="utf-8")
proc = MagicMock() proc = MagicMock()
proc.communicate.return_value = ("ok\n", "") proc.communicate.return_value = ("ok\n", "")
@ -384,6 +387,90 @@ class TestRunPython(unittest.TestCase):
tmp_subroot = executor.user_root / TMP_SUBDIR / str(ctx.task_id) tmp_subroot = executor.user_root / TMP_SUBDIR / str(ctx.task_id)
self.assertFalse(tmp_subroot.exists()) self.assertFalse(tmp_subroot.exists())
def test_run_python_script_path_not_found_errors_without_docker(self):
"""形态 1(没写就跑):宿主预检返带纠偏提示的 [Error],不起 docker exec。"""
executor, pool, _ = make_executor()
ctx = make_ctx(executor)
with patch("core.executor_docker.subprocess.Popen") as popen:
result = executor.call_tool(
"run_python", {"script_path": "scripts/nope.py"}, ctx
)
self.assertIn("script_path not found", result.content)
self.assertIn("write", result.content) # 纠偏提示:先 write 再执行
self.assertEqual(result.exit_code, 2)
popen.assert_not_called()
self.assertEqual(pool.ensure_calls, [])
def test_run_python_script_path_tolerates_workdir_prefix(self):
"""形态 2(双重拼接):fs 工具输出渲染 user_root 相对路径,模型回传
`demo/scripts/job.py` 时按 user_root 相对容错,不再拼成 /workspace/demo/demo/"""
executor, _, _ = make_executor()
ctx = make_ctx(executor)
script = executor.working_dir / "scripts" / "job.py"
script.parent.mkdir(parents=True)
script.write_text("print('ok')\n", encoding="utf-8")
proc = MagicMock()
proc.communicate.return_value = ("ok\n", "")
proc.returncode = 0
captured_argv = []
def _popen(argv, **kwargs):
captured_argv.append(argv)
return proc
with patch("core.executor_docker.subprocess.Popen", side_effect=_popen):
result = executor.call_tool(
"run_python", {"script_path": "demo/scripts/job.py"}, ctx
)
self.assertEqual(result.exit_code, 0)
self.assertEqual(captured_argv[0][-1], "/workspace/demo/scripts/job.py")
def test_run_python_code_saved_to_missing_script_path(self):
"""形态 3(code+script_path 同给):文件不存在 → code 落盘到 script_path 再执行。"""
executor, _, _ = make_executor()
ctx = make_ctx(executor)
proc = MagicMock()
proc.communicate.return_value = ("hi\n", "")
proc.returncode = 0
captured_argv = []
def _popen(argv, **kwargs):
captured_argv.append(argv)
return proc
with patch("core.executor_docker.subprocess.Popen", side_effect=_popen):
result = executor.call_tool(
"run_python",
{"script_path": "scripts/gen.py", "code": "print('hi')"},
ctx,
)
self.assertEqual(result.exit_code, 0)
self.assertEqual(captured_argv[0][-1], "/workspace/demo/scripts/gen.py")
saved = executor.working_dir / "scripts" / "gen.py"
self.assertEqual(saved.read_text(encoding="utf-8"), "print('hi')")
def test_run_python_script_path_out_of_bounds(self):
"""code 回写发生在宿主侧,越界路径必须拒(容器物理边界护不到 host write)。"""
executor, _, _ = make_executor()
ctx = make_ctx(executor)
with patch("core.executor_docker.subprocess.Popen") as popen:
result = executor.call_tool(
"run_python",
{"script_path": "scripts/../../../evil.py", "code": "x"},
ctx,
)
self.assertIn("out of bounds", result.content)
self.assertEqual(result.exit_code, 2)
popen.assert_not_called()
def test_run_python_bad_code_type(self): def test_run_python_bad_code_type(self):
executor, _, _ = make_executor() executor, _, _ = make_executor()
ctx = make_ctx(executor) ctx = make_ctx(executor)

View File

@ -22,6 +22,57 @@ class RunPythonScriptPathTests(unittest.TestCase):
self.assertIn("[Error]", out) self.assertIn("[Error]", out)
self.assertIn("code or script_path", out) self.assertIn("code or script_path", out)
def test_missing_script_path_errors_with_hint(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
out = RunPythonTool(base_dir=tmp).execute(script_path="scripts/nope.py")
self.assertIn("script_path not found", out)
self.assertIn("write", out)
def test_code_saved_to_missing_script_path_then_executed(self) -> None:
"""code + script_path 同给且文件不存在 → code 落盘到 script_path 再执行(0.58.22)。"""
with tempfile.TemporaryDirectory() as tmp:
out = RunPythonTool(base_dir=tmp).execute(
script_path="scripts/gen.py", code="print('saved-and-run')"
)
saved = Path(tmp) / "scripts" / "gen.py"
self.assertTrue(saved.is_file())
self.assertEqual(saved.read_text(encoding="utf-8"), "print('saved-and-run')")
self.assertIn("[stdout]\nsaved-and-run", out)
self.assertIn("[exit 0]", out)
def test_existing_script_path_wins_over_code(self) -> None:
"""文件已存在时 script_path 优先,code 不覆盖已有文件。"""
with tempfile.TemporaryDirectory() as tmp:
script = Path(tmp) / "job.py"
script.write_text("print('from file')\n", encoding="utf-8")
out = RunPythonTool(base_dir=tmp).execute(
script_path="job.py", code="print('from code')"
)
self.assertEqual(
script.read_text(encoding="utf-8"), "print('from file')\n"
)
self.assertIn("[stdout]\nfrom file", out)
def test_user_root_relative_fallback(self) -> None:
"""路径带工作目录名前缀(fs 工具输出的 user_root 相对形态)→ 容错解析。"""
with tempfile.TemporaryDirectory() as tmp:
user_root = Path(tmp)
task_dir = user_root / "demo"
script = task_dir / "scripts" / "job.py"
script.parent.mkdir(parents=True)
script.write_text("print('fallback ok')\n", encoding="utf-8")
out = RunPythonTool(base_dir=task_dir, user_root=user_root).execute(
script_path="demo/scripts/job.py"
)
self.assertIn("[stdout]\nfallback ok", out)
self.assertIn("[exit 0]", out)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@ -48,7 +48,7 @@ class RunPythonTool(Tool):
}, },
"script_path": { "script_path": {
"type": "string", "type": "string",
"description": "Path to an existing .py file to execute (relative to task_dir). Prefer this for non-trivial code; keep such scripts under scripts/.", "description": "Path to a .py file to execute (relative to task_dir). Prefer this for non-trivial code; keep such scripts under scripts/. If the file does not exist and code is also provided, code is saved to script_path first, then executed.",
}, },
"timeout": {"type": "integer", "default": 120, "description": "Seconds before kill. background=true 时含义变为最长运行时长,默认 7200s。"}, "timeout": {"type": "integer", "default": 120, "description": "Seconds before kill. background=true 时含义变为最长运行时长,默认 7200s。"},
"background": { "background": {
@ -73,6 +73,37 @@ class RunPythonTool(Tool):
super().__init__(base_dir, user_root=user_root) super().__init__(base_dir, user_root=user_root)
self.task_id = str(task_id) if task_id else "default" self.task_id = str(task_id) if task_id else "default"
def _prepare_script(self, script_path: str, code: str | None):
"""script_path → 宿主 Path,含存在性预检。返回 (script, err);err 非 None 时直接返给模型。
DockerExecutor._prepare_script 同语义(0.58.22,失败聚集can't open file」):
- workdir 相对不存在时按 user_root 相对再试(fs 工具输出渲染 user_root 相对
路径,模型会原样回传 双重拼接);
- code + script_path 同给且文件不存在 code 写到 script_path 再执行;
- 都不中 带纠偏提示的 [Error]
"""
script = self._resolve(script_path)
if not script.is_file() and self.user_root is not None:
alt = self.user_root / script_path.replace("\\", "/").strip().lstrip("./")
if alt.is_file():
script = alt
if script.is_file():
return script, None
if isinstance(code, str) and code.strip():
if self.user_root is not None:
try:
script.resolve().relative_to(self.user_root.resolve())
except (ValueError, OSError):
return None, f"[Error] script_path out of bounds: {script_path}"
script.parent.mkdir(parents=True, exist_ok=True)
script.write_text(code, encoding="utf-8")
return script, None
return None, (
f"[Error] script_path not found: {self._display(script)}"
"先用 write 创建该文件再执行,或直接用 code 传源码;"
"相对路径基于 task 工作目录,不要带工作目录名前缀。"
)
def _filtered_env(self) -> dict: def _filtered_env(self) -> dict:
env = os.environ.copy() env = os.environ.copy()
for k in list(env): for k in list(env):
@ -94,9 +125,9 @@ class RunPythonTool(Tool):
) )
timeout_s = procs.clamp_timeout(timeout if timeout and timeout > 120 else None) timeout_s = procs.clamp_timeout(timeout if timeout and timeout > 120 else None)
if script_path: if script_path:
script = self._resolve(script_path) script, err = self._prepare_script(script_path, code)
if not script.is_file(): if err is not None:
return f"[Error] script_path not found: {self._display(script)}" return err
proc_id, _ = procs.launch_host( proc_id, _ = procs.launch_host(
anchor, self.task_id, anchor, self.task_id,
kind="python", kind="python",
@ -133,9 +164,9 @@ class RunPythonTool(Tool):
return self._execute_background(code, script_path, timeout) return self._execute_background(code, script_path, timeout)
cleanup_script = False cleanup_script = False
if script_path: if script_path:
script = self._resolve(script_path) script, err = self._prepare_script(script_path, code)
if not script.is_file(): if err is not None:
return f"[Error] script_path not found: {self._display(script)}" return err
elif isinstance(code, str): elif isinstance(code, str):
# 写到临时文件,避免 -c 转义问题 # 写到临时文件,避免 -c 转义问题
with tempfile.NamedTemporaryFile( with tempfile.NamedTemporaryFile(