From 732420a3b7aea0a5e6831164ec5a147ebefb66b7 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Mon, 13 Jul 2026 16:41:43 +0800 Subject: [PATCH] =?UTF-8?q?fix(exec):=20run=5Fpython=20script=5Fpath=20?= =?UTF-8?q?=E5=AE=BF=E4=B8=BB=E9=A2=84=E6=A3=80+=E5=89=8D=E7=BC=80?= =?UTF-8?q?=E5=AE=B9=E9=94=99+code=20=E8=90=BD=E7=9B=98=E5=9B=9E=E9=80=80(?= =?UTF-8?q?bump=200.58.22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 失败聚集 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 --- PROGRESS.md | 1 + core/__init__.py | 2 +- core/executor_docker.py | 51 ++++++++++++++-- scripts/diag_run_python_noent.py | 81 ++++++++++++++++++++++++++ scripts/diag_run_python_noent2.py | 61 +++++++++++++++++++ tests/test_executor_docker.py | 87 ++++++++++++++++++++++++++++ tests/test_run_python_script_path.py | 51 ++++++++++++++++ tools/run_python.py | 45 +++++++++++--- 8 files changed, 366 insertions(+), 13 deletions(-) create mode 100644 scripts/diag_run_python_noent.py create mode 100644 scripts/diag_run_python_noent2.py diff --git a/PROGRESS.md b/PROGRESS.md index c8eb424..c4f8040 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -23,6 +23,7 @@ ### 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 `,模型拿裸 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///…`;现 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.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 行同步。 diff --git a/core/__init__.py b/core/__init__.py index 310ba79..3fa482c 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.58.21" +__version__ = "0.58.22" diff --git a/core/executor_docker.py b/core/executor_docker.py index 2364400..450c4b8 100644 --- a/core/executor_docker.py +++ b/core/executor_docker.py @@ -204,9 +204,11 @@ class DockerExecutor(Executor): content="[Error] bad arguments to run_python: script_path must be non-empty string", 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) container = self.pool.ensure(self.user_id) - container_script = self._container_script_path(script_path) argv = self._docker_exec_argv( container, extra_env=_sandbox_env({"PYTHONIOENCODING": "utf-8"}), @@ -295,7 +297,10 @@ class DockerExecutor(Executor): script_path = args.get("script_path") code = args.get("code") 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" elif isinstance(code, str) and code.strip(): (d / "script.py").write_text(code, encoding="utf-8", newline="\n") @@ -361,11 +366,47 @@ class DockerExecutor(Executor): 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/exit「can't open file」15 次/7 天):原实现 + 不预检直接 `docker exec python `,模型拿到裸 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() if p.startswith("/"): - return p - return self.container_workdir.rstrip("/") + "/" + p.lstrip("./") + if p != "/workspace" and not p.startswith("/workspace/"): + # /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)────────────────── diff --git a/scripts/diag_run_python_noent.py b/scripts/diag_run_python_noent.py new file mode 100644 index 0000000..4d33064 --- /dev/null +++ b/scripts/diag_run_python_noent.py @@ -0,0 +1,81 @@ +"""诊断 run_python 失败聚集: python: can't open file '': 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() diff --git a/scripts/diag_run_python_noent2.py b/scripts/diag_run_python_noent2.py new file mode 100644 index 0000000..ebab294 --- /dev/null +++ b/scripts/diag_run_python_noent2.py @@ -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() diff --git a/tests/test_executor_docker.py b/tests/test_executor_docker.py index 27e9a46..e24195e 100644 --- a/tests/test_executor_docker.py +++ b/tests/test_executor_docker.py @@ -361,6 +361,9 @@ class TestRunPython(unittest.TestCase): def test_run_python_script_path_uses_existing_workspace_file(self): executor, pool, _ = 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", "") @@ -384,6 +387,90 @@ class TestRunPython(unittest.TestCase): tmp_subroot = executor.user_root / TMP_SUBDIR / str(ctx.task_id) 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): executor, _, _ = make_executor() ctx = make_ctx(executor) diff --git a/tests/test_run_python_script_path.py b/tests/test_run_python_script_path.py index 285099d..4e69801 100644 --- a/tests/test_run_python_script_path.py +++ b/tests/test_run_python_script_path.py @@ -22,6 +22,57 @@ class RunPythonScriptPathTests(unittest.TestCase): self.assertIn("[Error]", 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__": unittest.main() diff --git a/tools/run_python.py b/tools/run_python.py index 616793b..c8dac97 100644 --- a/tools/run_python.py +++ b/tools/run_python.py @@ -48,7 +48,7 @@ class RunPythonTool(Tool): }, "script_path": { "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。"}, "background": { @@ -73,6 +73,37 @@ class RunPythonTool(Tool): super().__init__(base_dir, user_root=user_root) 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: env = os.environ.copy() 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) if script_path: - script = self._resolve(script_path) - if not script.is_file(): - return f"[Error] script_path not found: {self._display(script)}" + script, err = self._prepare_script(script_path, code) + if err is not None: + return err proc_id, _ = procs.launch_host( anchor, self.task_id, kind="python", @@ -133,9 +164,9 @@ class RunPythonTool(Tool): return self._execute_background(code, script_path, timeout) cleanup_script = False if script_path: - script = self._resolve(script_path) - if not script.is_file(): - return f"[Error] script_path not found: {self._display(script)}" + script, err = self._prepare_script(script_path, code) + if err is not None: + return err elif isinstance(code, str): # 写到临时文件,避免 -c 转义问题 with tempfile.NamedTemporaryFile(