Merge branch 'feat/2.6.0' of github.com:dataelement/bisheng into feat/2.6.0

This commit is contained in:
GuoQing Zhang
2026-07-15 15:28:30 +08:00
4 changed files with 245 additions and 73 deletions
@@ -1,3 +1,4 @@
import re
from abc import ABC, abstractmethod
from datetime import timedelta
from typing import Any
@@ -5,43 +6,76 @@ from typing import Any
from loguru import logger
from minio import Minio
# Deliverables must be written to the RELATIVE ``output/`` (or intermediate
# ``scratch/``) directory under the executor's working dir — that dir is the only
# location harvested back into the linsight workspace. A leading-slash
# ``/output/...`` lands at the container filesystem root, OUTSIDE the harvested
# working dir (and, for the shared LocalExecutor, cannot be safely rescued from
# there without leaking one task's files into another). Such a write therefore
# vanishes silently from the result panel. This regex flags the pattern in
# submitted code (string literal starting with ``/output`` or ``/scratch``) so the
# executor can append a corrective notice and the model self-corrects next step.
_ABSOLUTE_DELIVERABLE_RE = re.compile(r"""['"]/(?:output|scratch)(?:/|['"])""")
ABSOLUTE_PATH_NOTICE = (
"\n\n[SYSTEM NOTICE] Your code wrote file(s) to an ABSOLUTE path "
"(/output/... or /scratch/...). Files written outside the current working "
"directory are DISCARDED and were NOT delivered to the user. Re-run and write "
"to the RELATIVE path with no leading slash, e.g. `output/report.pdf` for "
"deliverables or `scratch/temp.png` for intermediate files."
)
class BaseExecutor(ABC):
def __init__(self, minio: dict, **kwargs):
self.minio = minio
# 将代码生成的文件同步到本地的路径
self.local_sync_path = kwargs.get('local_sync_path', None)
self.local_sync_path = kwargs.get("local_sync_path", None)
@abstractmethod
def run(self, code: str) -> Any:
raise NotImplementedError()
@staticmethod
def absolute_path_advisory(code: str) -> str:
"""Corrective notice to append when ``code`` writes to an absolute
``/output``/``/scratch`` path (which escapes the harvested working dir and
makes the deliverable silently vanish); empty string otherwise.
String-literal match only (leading-slash ``/output`` / ``/scratch``), which
is specific enough that false positives are negligible, and the notice is
non-blocking (appended to the tool result, never rejects the run).
"""
if code and _ABSOLUTE_DELIVERABLE_RE.search(code):
return ABSOLUTE_PATH_NOTICE
return ""
def upload_minio(
self,
object_name: str,
file_path,
self,
object_name: str,
file_path,
) -> str:
# 初始化minio
if not self.minio:
return ""
minio_client = Minio(
endpoint=self.minio.get('endpoint'),
access_key=self.minio.get('access_key'),
secret_key=self.minio.get('secret_key'),
secure=self.minio.get('schema') or self.minio.get('secure'),
cert_check=self.minio.get('cert_check'),
endpoint=self.minio.get("endpoint"),
access_key=self.minio.get("access_key"),
secret_key=self.minio.get("secret_key"),
secure=self.minio.get("schema") or self.minio.get("secure"),
cert_check=self.minio.get("cert_check"),
)
minio_share = Minio(
endpoint=self.minio.get('sharepoint'),
access_key=self.minio.get('access_key'),
secret_key=self.minio.get('secret_key'),
secure=self.minio.get('share_schema', False),
cert_check=self.minio.get('share_cert_check', False),
endpoint=self.minio.get("sharepoint"),
access_key=self.minio.get("access_key"),
secret_key=self.minio.get("secret_key"),
secure=self.minio.get("share_schema", False),
cert_check=self.minio.get("share_cert_check", False),
)
bucket = self.minio.get('tmp_bucket', 'tmp-dir')
bucket = self.minio.get("tmp_bucket", "tmp-dir")
logger.debug(
'upload_file obj={} bucket={} file_path={}',
"upload_file obj={} bucket={} file_path={}",
object_name,
bucket,
file_path,
@@ -72,7 +72,13 @@ class E2bCodeExecutor(BaseExecutor):
@property
def description(self) -> str:
return "A code interpreter that can execute python code. Input should be a valid python code. If you have any files outputted write them to `output/` relative to the execution path."
return (
"A code interpreter that can execute python code. Input should be valid python code. "
"Write final deliverables to the RELATIVE directory `output/` (e.g. `output/report.pdf`) "
"and intermediate files to `scratch/`. NEVER use an absolute path with a leading slash "
"such as `/output/...` or `/scratch/...` — files written outside the current working "
"directory are DISCARDED and will NOT be delivered to the user."
)
def init_sandbox(self):
if not self.sandbox:
@@ -10,7 +10,7 @@ from concurrent.futures import ThreadPoolExecutor, TimeoutError
from hashlib import md5
from os import DirEntry
from pathlib import Path
from typing import List, Tuple, Optional, Any
from typing import Any
import matplotlib
from loguru import logger
@@ -19,10 +19,10 @@ from bisheng_langchain.gpts.tools.code_interpreter.base_executor import BaseExec
CODE_BLOCK_PATTERN = r"```(\w*)\n(.*?)\n```"
DEFAULT_TIMEOUT = 600
WIN32 = sys.platform == 'win32'
PATH_SEPARATOR = WIN32 and '\\' or '/'
WORKING_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'extensions')
TIMEOUT_MSG = 'Timeout'
WIN32 = sys.platform == "win32"
PATH_SEPARATOR = (WIN32 and "\\") or "/"
WORKING_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), "extensions")
TIMEOUT_MSG = "Timeout"
UNKNOWN = "unknown"
LOCAL_DESCRIPTION = """Evaluates python code in native environment. \
@@ -30,11 +30,13 @@ You must send the whole script every time and print your outputs. \
Script should be pure python code that can be evaluated. \
It should be in python format NOT markdown. \
The code should NOT be wrapped in backticks. \
If you have any files outputted write them to "output/" relative to the execution \
path. Output can only be read from the directory, stdout, and stdin. \
Do not use things like plot.show() as it will \
not work instead write them out `output/`\
print() any output and results so you can capture the output.""" # noqa: T201
FILE OUTPUT RULES (STRICT): write final deliverables to the RELATIVE directory \
`output/` (e.g. `output/report.pdf`) and intermediate files to `scratch/`; these \
are subfolders of the current working directory. NEVER use an absolute path with a \
leading slash such as `/output/...` or `/scratch/...` — anything written outside the \
current working directory is DISCARDED and will NOT be delivered to the user. \
Do not use things like plot.show() as it will not work; save figures to `output/` \
instead. print() any output and results so you can capture the output."""
class LocalExecutor(BaseExecutor):
@@ -66,25 +68,25 @@ class LocalExecutor(BaseExecutor):
def insert_set_font_code(code: str) -> str:
"""判断python代码中是否导入了matplotlib库,如果有则插入设置字体的代码"""
split_code = code.split('\n')
split_code = code.split("\n")
cache_file = matplotlib.get_cachedir()
font_cache = glob.glob(f'{cache_file}/fontlist*')
font_cache = glob.glob(f"{cache_file}/fontlist*")
for cache in font_cache:
os.remove(cache)
# todo: 如果生成的代码中已经有了设置字体的代码,可能会导致该段代码失效
if 'matplotlib' in code:
pattern = re.compile(r'(import matplotlib|from matplotlib)')
if "matplotlib" in code:
pattern = re.compile(r"(import matplotlib|from matplotlib)")
index = max(i for i, line in enumerate(split_code) if pattern.search(line))
split_code.insert(index + 1, 'import matplotlib\nmatplotlib.rc("font", family="WenQuanYi Zen Hei")')
return '\n'.join(split_code)
return "\n".join(split_code)
@staticmethod
def extract_code(
text: str, pattern: str = CODE_BLOCK_PATTERN, detect_single_line_code: bool = False
) -> List[Tuple[str, str]]:
text: str, pattern: str = CODE_BLOCK_PATTERN, detect_single_line_code: bool = False
) -> list[tuple[str, str]]:
"""Extract code from a text.
Args:
@@ -122,28 +124,30 @@ class LocalExecutor(BaseExecutor):
@staticmethod
def _cmd(lang):
if lang.startswith('python') or lang in ['bash', 'sh', 'powershell']:
if lang.startswith("python") or lang in ["bash", "sh", "powershell"]:
return lang
if lang in ['shell']:
return 'sh'
if lang in ['ps1']:
return 'powershell'
raise NotImplementedError(f'{lang} not recognized in code execution')
if lang in ["shell"]:
return "sh"
if lang in ["ps1"]:
return "powershell"
raise NotImplementedError(f"{lang} not recognized in code execution")
@classmethod
def _execute_code(cls,
code: Optional[str] = None,
timeout: Optional[int] = None,
filename: Optional[str] = None,
work_dir: Optional[str] = None,
lang: Optional[str] = 'python',
file_path: Optional[str] = None):
def _execute_code(
cls,
code: str | None = None,
timeout: int | None = None,
filename: str | None = None,
work_dir: str | None = None,
lang: str | None = "python",
file_path: str | None = None,
):
cmd = [
sys.executable if lang.startswith('python') else cls._cmd(lang),
f'.\\{filename}' if WIN32 else filename,
sys.executable if lang.startswith("python") else cls._cmd(lang),
f".\\{filename}" if WIN32 else filename,
]
if WIN32:
logger.warning('SIGALRM is not supported on Windows. No timeout will be enforced.')
logger.warning("SIGALRM is not supported on Windows. No timeout will be enforced.")
result = subprocess.run(
cmd,
cwd=work_dir,
@@ -167,25 +171,25 @@ class LocalExecutor(BaseExecutor):
logs = result.stderr
if file_path is not None:
abs_path = str(Path(file_path).absolute())
logs = logs.replace(str(abs_path), '').replace(filename, '')
logs = logs.replace(str(abs_path), "").replace(filename, "")
else:
abs_path = str(Path(work_dir).absolute()) + PATH_SEPARATOR
logs = logs.replace(str(abs_path), '')
logs = logs.replace(str(abs_path), "")
else:
logs = result.stdout
return result.returncode, logs, ""
@classmethod
def execute_code(
cls,
code: Optional[str] = None,
timeout: Optional[int] = None,
filename: Optional[str] = None,
work_dir: Optional[str] = None,
lang: Optional[str] = 'python',
) -> Tuple[int, str, str]:
cls,
code: str | None = None,
timeout: int | None = None,
filename: str | None = None,
work_dir: str | None = None,
lang: str | None = "python",
) -> tuple[int, str, str]:
if all((code is None, filename is None)):
error_msg = f'Either {code=} or {filename=} must be provided.'
error_msg = f"Either {code=} or {filename=} must be provided."
logger.error(error_msg)
raise AssertionError(error_msg)
@@ -201,13 +205,14 @@ class LocalExecutor(BaseExecutor):
filepath = os.path.join(work_dir, filename)
file_dir = os.path.dirname(filepath)
os.makedirs(file_dir, exist_ok=True)
(Path(file_dir) / 'output').mkdir(exist_ok=True, parents=True)
(Path(file_dir) / "output").mkdir(exist_ok=True, parents=True)
if code is not None:
with open(filepath, 'w', encoding='utf-8') as fout:
with open(filepath, "w", encoding="utf-8") as fout:
fout.write(code)
try:
return cls._execute_code(code=code, timeout=timeout, filename=filename, work_dir=work_dir, lang=lang,
file_path=filepath)
return cls._execute_code(
code=code, timeout=timeout, filename=filename, work_dir=work_dir, lang=lang, file_path=filepath
)
finally:
if filepath is not None:
os.remove(filepath)
@@ -219,7 +224,7 @@ class LocalExecutor(BaseExecutor):
work_dir=dir_path,
lang=lang,
)
logs += '\n' + logs
logs += "\n" + logs
file_list = []
if exitcode != 0:
return exitcode, logs, file_list
@@ -237,8 +242,9 @@ class LocalExecutor(BaseExecutor):
return exitcode, logs, file_list
def run(self, code: str) -> Any:
original_code = code
code_blocks = self.extract_code(code)
logs_all = ''
logs_all = ""
all_file_list = []
for i, code_block in enumerate(code_blocks):
lang, code = code_block
@@ -250,13 +256,20 @@ class LocalExecutor(BaseExecutor):
with tempfile.TemporaryDirectory() as temp_dir:
exit_code, logs, file_list = self.run_with_dir(code, dir_path=temp_dir, lang=lang)
if exit_code != 0:
return {'exitcode': exit_code, 'log': logs_all}
return {"exitcode": exit_code, "log": logs_all}
logs_all += "\n" + logs
all_file_list += file_list
return {'exitcode': 0, 'log': logs_all, 'file_list': all_file_list}
# Deterministic safety net: if the script wrote a deliverable to an absolute
# /output//scratch path it escaped the harvested working dir and silently
# vanished (see base_executor). Append a corrective notice so the model
# re-writes with a relative path on the next step. Non-blocking.
advisory = self.absolute_path_advisory(original_code)
if advisory:
logs_all += advisory
return {"exitcode": 0, "log": logs_all, "file_list": all_file_list}
def sync_files_to_local(self, files_info: List[DirEntry], root_path: str):
def sync_files_to_local(self, files_info: list[DirEntry], root_path: str):
if not files_info:
return
for file in files_info:
@@ -277,11 +290,15 @@ class LocalExecutor(BaseExecutor):
shutil.move(file_info.path, local_path)
if __name__ == '__main__':
tmp_executor = LocalExecutor(minio={}, )
if __name__ == "__main__":
tmp_executor = LocalExecutor(
minio={},
)
result = tmp_executor.run(
code="""import os\nwith open("output/test2.txt", "w") as f:\n f.write("Hello, E2222B!")\nprint("File written to output/test.txt")""")
code="""import os\nwith open("output/test2.txt", "w") as f:\n f.write("Hello, E2222B!")\nprint("File written to output/test.txt")"""
)
result2 = tmp_executor.run(
code="""import os\nwith open("output/test2.txt", "r") as f:\n content = f.read()\n print(f"File read from output/test2.txt=={content}")""")
code="""import os\nwith open("output/test2.txt", "r") as f:\n content = f.read()\n print(f"File read from output/test2.txt=={content}")"""
)
print(result)
print(result2)
@@ -0,0 +1,115 @@
"""Code-interpreter deliverables must land under the RELATIVE ``output/`` dir.
Root cause of the "PDF vanished from the workspace" bug: the model wrote the
deliverable to an ABSOLUTE path (``/output/report.pdf``) which resolves to the
container filesystem root — outside the per-task working dir the executor harvests
into the linsight workspace. The file was uploaded nowhere (``file_list == []``),
never synced, never picked up by ``get_final_result_file``, and the result panel
fell back to a synthesized ``报告.md``.
The shared LocalExecutor cannot safely rescue container-root files (that would leak
one task's output into another), so the fix is: (1) a strict relative-path contract
in the tool description, and (2) a deterministic, non-blocking corrective notice
appended to the tool result whenever a run wrote to an absolute ``/output``/
``/scratch`` path, so the model self-corrects on the next step.
These tests cover the detection helper and the LocalExecutor notice wiring; no real
subprocess / matplotlib / MinIO is involved.
"""
from __future__ import annotations
import pytest
from bisheng_langchain.gpts.tools.code_interpreter.base_executor import (
ABSOLUTE_PATH_NOTICE,
BaseExecutor,
)
from bisheng_langchain.gpts.tools.code_interpreter.local_executor import LocalExecutor
# ---------------------------------------------------------------------------
# absolute_path_advisory: flags leading-slash /output|/scratch string literals only
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"code",
[
"with open('/output/report.pdf', 'wb') as f: f.write(b'x')",
'plt.savefig("/scratch/chart.png")',
"pdf.output('/output/油脂油料市场早报.pdf')",
"open('/output')", # no trailing slash, still absolute deliverable root
"open('/scratch')",
],
)
def test_advisory_flags_absolute_deliverable_paths(code):
assert BaseExecutor.absolute_path_advisory(code) == ABSOLUTE_PATH_NOTICE
@pytest.mark.parametrize(
"code",
[
"with open('output/report.pdf', 'wb') as f: f.write(b'x')", # relative — correct
"plt.savefig('scratch/chart.png')", # relative — correct
"open('./output/report.pdf')", # relative with ./
"url = 'https://host/output/y'", # /output mid-string, not a path root
"open('/data/output/x')", # path root is /data, not /output
"p = '/outputs/x'", # different word (/outputs), not /output
"print('hello world')",
"",
None,
],
)
def test_advisory_silent_for_relative_or_unrelated(code):
assert BaseExecutor.absolute_path_advisory(code) == ""
# ---------------------------------------------------------------------------
# LocalExecutor.run: appends the corrective notice for absolute-path writes
# ---------------------------------------------------------------------------
def _make_executor(monkeypatch):
"""LocalExecutor with subprocess execution + matplotlib font side effects
stubbed out, so run() exercises only the loop + notice-append logic."""
exe = LocalExecutor(minio={})
exe.local_sync_path = None # take the TemporaryDirectory branch
# stub the actual run so no python subprocess is spawned
monkeypatch.setattr(exe, "run_with_dir", lambda code, dir_path, lang: (0, "stdout-ok\n", []))
# stub the matplotlib font injection (touches the mpl cache otherwise)
monkeypatch.setattr(exe, "insert_set_font_code", lambda code: code)
return exe
def test_run_appends_notice_for_absolute_output(monkeypatch):
exe = _make_executor(monkeypatch)
result = exe.run("with open('/output/report.pdf', 'wb') as f:\n f.write(b'x')")
assert result["exitcode"] == 0
assert ABSOLUTE_PATH_NOTICE in result["log"]
# original stdout is preserved alongside the notice
assert "stdout-ok" in result["log"]
def test_run_no_notice_for_relative_output(monkeypatch):
exe = _make_executor(monkeypatch)
result = exe.run("with open('output/report.pdf', 'wb') as f:\n f.write(b'x')")
assert result["exitcode"] == 0
assert ABSOLUTE_PATH_NOTICE not in result["log"]
assert "stdout-ok" in result["log"]
def test_run_notice_uses_original_code_not_last_block(monkeypatch):
"""The notice keys off the full submitted script, not the loop's reassigned
``code`` var (which would otherwise hold only the last code block)."""
exe = _make_executor(monkeypatch)
result = exe.run("import os\nos.makedirs('/scratch/charts', exist_ok=True)\nprint('done')")
assert ABSOLUTE_PATH_NOTICE in result["log"]
def test_run_failure_path_returns_without_notice(monkeypatch):
exe = LocalExecutor(minio={})
exe.local_sync_path = None
monkeypatch.setattr(exe, "insert_set_font_code", lambda code: code)
monkeypatch.setattr(exe, "run_with_dir", lambda code, dir_path, lang: (1, "boom\n", []))
result = exe.run("open('/output/x.pdf', 'wb')")
assert result["exitcode"] == 1
assert "log" in result
# failure path returns early — no file_list, no notice appended
assert "file_list" not in result