diff --git a/src/langbot/pkg/provider/tools/loaders/native.py b/src/langbot/pkg/provider/tools/loaders/native.py index 9d03deb26..5182ee9af 100644 --- a/src/langbot/pkg/provider/tools/loaders/native.py +++ b/src/langbot/pkg/provider/tools/loaders/native.py @@ -747,10 +747,18 @@ class NativeToolLoader(loader.ToolLoader): return f'{str(base).rstrip("/")}/{relative}' async def _run_workspace_file_script(self, script: str, query: pipeline_query.Query) -> dict: - # Sandbox images built from a host rootfs may only ship `python3`, and a - # read-only /usr prevents creating a `python` shim inside the jail, so - # resolve the interpreter instead of assuming its name. - command = f'PYTHON_BIN=$(command -v python3 || command -v python); "$PYTHON_BIN" - <<\'PY\'\n{script}\nPY' + # Box file tools need Python 3 (f-strings, etc.). Prefer `python3` and + # never fall back to unversioned `python`, which may still be Python 2 + # on host-rootfs nsjail mounts (LangBot #2603). Missing python3 yields a + # clear JSON error instead of a SyntaxError tool result. + command = ( + 'PYTHON_BIN=$(command -v python3 || true); ' + 'if [ -z "$PYTHON_BIN" ]; then ' + 'printf \'%s\\n\' \'{"ok": false, "error": "Python 3 interpreter not found in Box (python3 required for native file tools)"}\'; ' + 'exit 0; ' + 'fi; ' + f'"$PYTHON_BIN" - <<\'PY\'\n{script}\nPY' + ) result = await self.ap.box_service.execute_tool( { 'command': command, diff --git a/tests/unit_tests/provider/test_tool_manager_native.py b/tests/unit_tests/provider/test_tool_manager_native.py index 767e1d7b6..7dac7691c 100644 --- a/tests/unit_tests/provider/test_tool_manager_native.py +++ b/tests/unit_tests/provider/test_tool_manager_native.py @@ -866,7 +866,7 @@ async def test_box_grep_script_serializes_optional_include_as_python(monkeypatch @pytest.mark.asyncio async def test_workspace_file_script_resolves_the_sandbox_interpreter(): - """Sandboxes built from a host rootfs may only ship `python3`.""" + """Native file tools must use python3 and never fall back to python2.""" captured: dict = {} @@ -892,5 +892,7 @@ async def test_workspace_file_script_resolves_the_sandbox_interpreter(): assert result == {'ok': True} command = captured['command'] - assert command.startswith('PYTHON_BIN=$(command -v python3 || command -v python)') + assert 'command -v python3 || true' in command + assert 'python3 required for native file tools' in command assert command.endswith(' - <<\'PY\'\nprint("x")\nPY') + assert '|| command -v python)' not in command