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:
parent
859c02cf65
commit
732420a3b7
|
|
@ -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 <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.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 行同步。
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||
# 改版本只动这一行。
|
||||
__version__ = "0.58.21"
|
||||
__version__ = "0.58.22"
|
||||
|
|
|
|||
|
|
@ -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 <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()
|
||||
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)──────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue