From b66d35b425d3a78343f73076bc51bf590703f277 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sun, 31 May 2026 05:55:00 -0700 Subject: [PATCH 01/31] Studio: stop hidden source badges from inflating chat scroll height (#5822) The SourcesGroup measurement container renders every citation badge off-screen with `invisible absolute` so we can measure how many fit in two rows. Absolute descendants still contribute to the parent's scrollable overflow region, so long source lists added hundreds of pixels of phantom scroll space below the assistant message that the user could scroll into. Wrap the measurement container in an absolute, `h-0`, `overflow-hidden` box so the off-screen pills are clipped out of the scrollable overflow region. Measurement still works because offsetTop is read relative to the positioned wrapper. --- .../src/components/assistant-ui/sources.tsx | 36 +++++++++++++------ 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/studio/frontend/src/components/assistant-ui/sources.tsx b/studio/frontend/src/components/assistant-ui/sources.tsx index 140b61f932..5593369bf9 100644 --- a/studio/frontend/src/components/assistant-ui/sources.tsx +++ b/studio/frontend/src/components/assistant-ui/sources.tsx @@ -263,20 +263,34 @@ const SourcesGroup: FC = () => { return (
- {/* Hidden measurement container — renders all badges to measure row positions */} + {/* Hidden measurement container. Renders all badges off-screen so we + can read each child's offsetTop and decide how many fit in two + rows. Wrapped in an absolute, h-0, overflow-hidden box so the + measurement pills do NOT contribute to the viewport's scrollable + overflow region. Without this clip, every hidden source row + adds ~30px to scrollHeight, producing a phantom empty scroll + area below the message: visible to users as unbounded blank + space below the assistant action bar. The inner div still + flex-wraps its children for measurement; offsetTop reads + correctly because the wrapper is positioned (absolute) and the + children's offsetTop is measured relative to it. */}
- {sources.map((source) => ( - - - - {source.title || extractDomain(source.url)} - - - ))} +
+ {sources.map((source) => ( + + + + {source.title || extractDomain(source.url)} + + + ))} +
{/* Visible container */} From dad2695fdea49c2318ce27ff6307892b77f6f533 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sun, 31 May 2026 06:18:30 -0700 Subject: [PATCH 02/31] Studio: pin the last pre-macOS-26 llama.cpp prebuilt instead of walking back (#5896) * Studio: pin the last pre-macOS-26 llama.cpp prebuilt instead of walking back ggml-org moved their macOS build runner to macOS 26 (Tahoe) at b9428, so b9428 and every newer upstream prebuilt is stamped minos 26 and fails to dyld-load on macOS 14 / 15. #5883 handled this by walking back release by release at install time. Replace that with a deterministic pin: a host below macOS 26 selects b9415 directly (the last upstream build stamped below 26: arm64 minos 14, x64 minos 13.3), so it loads on macOS 13.3 / 14 / 15 / 26. Hosts on macOS 26+ and unknown-version hosts keep latest selection unchanged. Only the ggml-org upstream path is pinned; the unslothai/llama.cpp fork ships its own minos-13.3 prebuilts (#5893), so the pin is a no-op there and goes dormant once macOS routes to the fork. The Mach-O minos preflight from #5883 stays as a post-download backstop. Refs #5883, #5893. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * docs: fork ships arm64 minos 14 / x64 minos 13.3, not uniform 13.3 The per-slice fork producer pins arm64 to 14.0 and x64 to 13.3; update the pinned_macos_release_tag docstring to match. No behavior change. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: danielhanchen --- studio/install_llama_prebuilt.py | 46 +++++-- .../install/test_macos_version_compat.py | 46 ++++--- tests/studio/install/test_selection_logic.py | 127 ++++++++++++++++++ 3 files changed, 192 insertions(+), 27 deletions(-) diff --git a/studio/install_llama_prebuilt.py b/studio/install_llama_prebuilt.py index 861a501570..2afd54bec9 100644 --- a/studio/install_llama_prebuilt.py +++ b/studio/install_llama_prebuilt.py @@ -169,6 +169,11 @@ DEFAULT_MAX_MACOS_RELEASE_FALLBACKS = env_int( 16, minimum = 1, ) +# Deterministic macOS pin. At b9428 ggml-org's macOS runner moved to macOS 26 +# (Tahoe), so b9428+ prebuilts only load on macOS 26+. b9415 is the last build +# stamped below 26 (arm64 minos 14, x64 minos 13.3); loads on macOS 13.3/14/15/26. +_PINNED_MACOS_FALLBACK_TAG = "b9415" +_PINNED_MACOS_LATEST_FLOOR = (26, 0) FORCE_COMPILE_DEFAULT_REF = os.environ.get("UNSLOTH_LLAMA_FORCE_COMPILE_REF", "master") DIRECT_LINUX_BUNDLE_PROFILES: dict[str, dict[str, Any]] = { @@ -1616,6 +1621,24 @@ def direct_upstream_release_plan( ) +def pinned_macos_release_tag(host: HostInfo, repo: str) -> str | None: + """Pin b9415 (the last upstream macOS build that loads below macOS 26) for a + known pre-26 host on ggml-org upstream; return None to keep latest selection. + The unslothai/llama.cpp fork ships its own prebuilts (arm64 minos 14, x64 + minos 13.3) and needs no pin, so this is a no-op there and for macOS 26+, + unknown version, non-macOS.""" + if repo != UPSTREAM_REPO: + return None + if not host.is_macos: + return None + version = host.macos_version + if version is None: + return None + if version >= _PINNED_MACOS_LATEST_FLOOR: + return None + return _PINNED_MACOS_FALLBACK_TAG + + def resolve_simple_install_release_plans( llama_tag: str, host: HostInfo, @@ -1629,15 +1652,15 @@ def resolve_simple_install_release_plans( allow_older_release_fallback = ( requested_tag == "latest" and not published_release_tag ) + # macOS: pin the last upstream build that loads on a pre-26 host instead of + # fetching the latest (macOS 26 only) build and walking back release by + # release. No-op on macOS 26+, unknown version, non-macOS, and the fork. + if allow_older_release_fallback: + pinned_macos = pinned_macos_release_tag(host, repo) + if pinned_macos is not None: + requested_tag = pinned_macos + allow_older_release_fallback = False release_limit = max(1, max_release_fallbacks) - # macOS may need to walk past a run of too-new prebuilts. Only when the host - # version is known; otherwise keep the default (cannot tell up front). - if ( - host.is_macos - and allow_older_release_fallback - and host.macos_version is not None - ): - release_limit = max(release_limit, DEFAULT_MAX_MACOS_RELEASE_FALLBACKS) plans: list[InstallReleasePlan] = [] last_error: PrebuiltFallback | None = None @@ -5303,9 +5326,10 @@ def preflight_macos_installed_binaries( install_dir: Path, host: HostInfo, ) -> None: - """Reject a macos prebuilt whose minimum-OS is newer than the host so the - release walk-back advances to the newest compatible release. No-op when the - host macOS version is unknown (runtime validation remains the backstop).""" + """Reject a macos prebuilt whose minimum-OS is newer than the host. The + upstream selector pins a loadable release up front, so here this is the + post-download backstop; the published/fork path also uses it to advance the + walk-back. No-op when the host macOS version is unknown (runtime validates).""" if not host.is_macos or host.macos_version is None: return issues = macos_binary_minos_issues(binaries, install_dir, host) diff --git a/tests/studio/install/test_macos_version_compat.py b/tests/studio/install/test_macos_version_compat.py index 0adc91a622..1b87e5af65 100644 --- a/tests/studio/install/test_macos_version_compat.py +++ b/tests/studio/install/test_macos_version_compat.py @@ -238,32 +238,46 @@ def _fake_macos_releases(tags): ] -class TestMacosReleaseWalkback: - """A known-version macOS host must generate enough older-release plans to - walk back past a run of too-new prebuilts; unknown-version and non-macOS - hosts keep the conservative 2-release default.""" +class TestMacosReleasePin: + """A known pre-26 macOS host deterministically pins the last upstream release + whose prebuilt loads on it (b9415) instead of walking back release by release; + macOS 26+ and unknown-version hosts keep normal latest selection with the + conservative 2-release default.""" - TAGS = [f"b{n}" for n in range(9437, 9400, -1)] # 37 newest-first releases + TAGS = [f"b{n}" for n in range(9442, 9400, -1)] # newest-first, includes b9415 def _patch_releases(self, monkeypatch): - monkeypatch.setattr( - ILP, - "iter_release_payloads_by_time", - lambda repo, published_release_tag, requested_tag: _fake_macos_releases( - self.TAGS - ), - ) + def fake_iter(repo, published_release_tag, requested_tag): + # The real iterator yields only the requested tag when one is pinned. + if requested_tag and requested_tag != "latest": + return _fake_macos_releases([requested_tag]) + return _fake_macos_releases(self.TAGS) - def test_known_macos_host_walks_back_deeper(self, monkeypatch): + monkeypatch.setattr(ILP, "iter_release_payloads_by_time", fake_iter) + + def test_pre26_host_pins_b9415(self, monkeypatch): self._patch_releases(monkeypatch) - _tag, plans = ILP.resolve_simple_install_release_plans( + tag, plans = ILP.resolve_simple_install_release_plans( "latest", make_macos_host((14, 0)), "ggml-org/llama.cpp", "", ) - assert len(plans) == ILP.DEFAULT_MAX_MACOS_RELEASE_FALLBACKS - assert len(plans) > ILP.DEFAULT_MAX_PREBUILT_RELEASE_FALLBACKS + assert tag == ILP._PINNED_MACOS_FALLBACK_TAG == "b9415" + assert len(plans) == 1 + assert plans[0].release_tag == "b9415" + + def test_tahoe_host_takes_latest(self, monkeypatch): + self._patch_releases(monkeypatch) + tag, plans = ILP.resolve_simple_install_release_plans( + "latest", + make_macos_host((26, 0)), + "ggml-org/llama.cpp", + "", + ) + assert tag == "latest" + assert plans[0].release_tag == self.TAGS[0] # newest release + assert len(plans) == ILP.DEFAULT_MAX_PREBUILT_RELEASE_FALLBACKS def test_unknown_macos_host_uses_default(self, monkeypatch): self._patch_releases(monkeypatch) diff --git a/tests/studio/install/test_selection_logic.py b/tests/studio/install/test_selection_logic.py index 16f47fa410..78aa9c480e 100644 --- a/tests/studio/install/test_selection_logic.py +++ b/tests/studio/install/test_selection_logic.py @@ -85,6 +85,10 @@ _windows_cuda_attempt_covers_blackwell = ( INSTALL_LLAMA_PREBUILT._windows_cuda_attempt_covers_blackwell ) resolve_release_asset_choice = INSTALL_LLAMA_PREBUILT.resolve_release_asset_choice +pinned_macos_release_tag = INSTALL_LLAMA_PREBUILT.pinned_macos_release_tag +resolve_simple_install_release_plans = ( + INSTALL_LLAMA_PREBUILT.resolve_simple_install_release_plans +) # --------------------------------------------------------------------------- @@ -2631,3 +2635,126 @@ class TestResolveUpstreamAssetChoice: result = resolve_upstream_asset_choice(host, self.TAG) assert result.install_kind == "windows-cuda" assert result.name == cuda_name + + +# =========================================================================== +# N.2. Deterministic macOS prebuilt pin (b9415) +# =========================================================================== + + +def _macos_host(machine = "arm64", version = (15, 5)): + return make_host( + system = "Darwin", + machine = machine, + nvidia_smi = None, + driver_cuda_version = None, + compute_caps = [], + has_physical_nvidia = False, + has_usable_nvidia = False, + macos_version = version, + ) + + +class TestPinnedMacosReleaseTag: + """pinned_macos_release_tag: pin b9415 only for ggml-org upstream macOS hosts + below macOS 26; latest (None) for 26+, unknown version, the fork, non-macOS.""" + + def test_arm64_sequoia_pins_b9415(self): + host = _macos_host("arm64", (15, 5)) + assert pinned_macos_release_tag(host, UPSTREAM_REPO) == "b9415" + + def test_arm64_sonoma_pins_b9415(self): + host = _macos_host("arm64", (14, 7)) + assert pinned_macos_release_tag(host, UPSTREAM_REPO) == "b9415" + + def test_x64_ventura_13_3_pins_b9415(self): + # b9415's Intel slice is minos 13.3, so 13.3 Intel hosts still load it. + host = _macos_host("x86_64", (13, 3)) + assert pinned_macos_release_tag(host, UPSTREAM_REPO) == "b9415" + + def test_tahoe_26_0_takes_latest(self): + host = _macos_host("arm64", (26, 0)) + assert pinned_macos_release_tag(host, UPSTREAM_REPO) is None + + def test_tahoe_26_1_takes_latest(self): + host = _macos_host("arm64", (26, 1)) + assert pinned_macos_release_tag(host, UPSTREAM_REPO) is None + + def test_unknown_version_takes_latest(self): + host = _macos_host("arm64", None) + assert pinned_macos_release_tag(host, UPSTREAM_REPO) is None + + def test_fork_repo_is_dormant(self): + # The unslothai/llama.cpp fork publishes its own minos-13.3 prebuilts. + host = _macos_host("arm64", (15, 5)) + fork = INSTALL_LLAMA_PREBUILT.DEFAULT_PUBLISHED_REPO + assert pinned_macos_release_tag(host, fork) is None + + def test_non_macos_host_is_dormant(self): + host = make_host(system = "Linux", machine = "x86_64") + assert pinned_macos_release_tag(host, UPSTREAM_REPO) is None + + +class TestResolveSimpleMacosPin: + """End to end on the simple/upstream path macOS actually uses: a pre-26 host + deterministically resolves b9415 (no walk-back); a macOS 26 host takes the + latest release. Mirrors how setup.sh routes Darwin to ggml-org/llama.cpp.""" + + TAGS = ["b9442", "b9430", "b9428", "b9415"] # newest-first feed + + def _feed(self, monkeypatch): + calls = [] + + def _release(tag): + name = f"llama-{tag}-bin-macos-arm64.tar.gz" + return { + "tag_name": tag, + "assets": [ + { + "name": name, + "browser_download_url": f"https://example.com/{name}", + } + ], + } + + def fake_iter(repo, published_release_tag = "", requested_tag = ""): + calls.append((repo, published_release_tag, requested_tag)) + # Emulate the real iterator: a specific tag yields only that release. + if requested_tag and requested_tag != "latest": + yield _release(requested_tag) + return + for tag in self.TAGS: + yield _release(tag) + + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, "iter_release_payloads_by_time", fake_iter + ) + return calls + + def test_pre26_host_pins_b9415_without_walkback(self, monkeypatch): + calls = self._feed(monkeypatch) + host = _macos_host("arm64", (15, 5)) + requested_tag, plans = resolve_simple_install_release_plans( + "latest", host, "ggml-org/llama.cpp", "" + ) + assert requested_tag == "b9415" + assert len(plans) == 1 + assert plans[0].release_tag == "b9415" + assert plans[0].llama_tag == "b9415" + assert plans[0].attempts[0].install_kind == "macos-arm64" + assert plans[0].attempts[0].name == "llama-b9415-bin-macos-arm64.tar.gz" + # The pin overrode the requested tag before any release was fetched. + assert calls[0][2] == "b9415" + # Simple/upstream path stays unverified-by-manifest, exactly as before. + assert plans[0].approved_checksums.artifacts == {} + + def test_tahoe_host_takes_latest_release(self, monkeypatch): + calls = self._feed(monkeypatch) + host = _macos_host("arm64", (26, 0)) + requested_tag, plans = resolve_simple_install_release_plans( + "latest", host, "ggml-org/llama.cpp", "" + ) + assert requested_tag == "latest" + assert plans[0].release_tag == "b9442" + # No pin: the iterator was asked for latest, not a specific tag. + assert calls[0][2] == "latest" From 2e1580f70bd9f1b9a8b2d35112f89047a4217e1d Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sun, 31 May 2026 06:29:11 -0700 Subject: [PATCH 03/31] Versioning --- pyproject.toml | 6 +++--- unsloth/models/_utils.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index aef88d90f5..acc65f12cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,7 +69,7 @@ triton = [ ] huggingfacenotorch = [ - "unsloth_zoo>=2026.5.4", + "unsloth_zoo>=2026.5.5", "wheel>=0.42.0", "packaging", "numpy", @@ -90,7 +90,7 @@ huggingfacenotorch = [ ] huggingface = [ "unsloth[huggingfacenotorch]", - "unsloth_zoo>=2026.5.4", + "unsloth_zoo>=2026.5.5", "torchvision", "unsloth[triton]", ] @@ -580,7 +580,7 @@ colab-ampere-torch220 = [ "flash-attn>=2.6.3 ; ('linux' in sys_platform)", ] colab-new = [ - "unsloth_zoo>=2026.5.4", + "unsloth_zoo>=2026.5.5", "packaging", "tyro", "transformers>=4.51.3,!=4.52.0,!=4.52.1,!=4.52.2,!=4.52.3,!=4.53.0,!=4.54.0,!=4.55.0,!=4.55.1,!=4.57.0,!=4.57.4,!=4.57.5,!=5.0.0,!=5.1.0,<=5.5.0", diff --git a/unsloth/models/_utils.py b/unsloth/models/_utils.py index 67b3468425..59d7305a79 100644 --- a/unsloth/models/_utils.py +++ b/unsloth/models/_utils.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2026.5.8" +__version__ = "2026.5.9" __all__ = [ "SUPPORTS_BFLOAT16", From 70394e1f85cf41c3b18e0731cab921ad8b25dcf1 Mon Sep 17 00:00:00 2001 From: Ramakrishna Bachu Date: Sun, 31 May 2026 13:30:36 +0000 Subject: [PATCH 04/31] fix(install): detect x86_64 Python venv on Apple Silicon and rebuild as arm64 On Apple Silicon, uv can create the venv from a cached x86_64 (Rosetta) Python, so the venv reports x86_64 to wheel resolvers and the torch install never resolves (the CPU index ships no macOS wheels). Detect an x86_64 venv on an arm64 host and recreate it with an arch-explicit arm64 CPython, re-inspecting before the existing 3.13.8 check so both invariants hold. Skipped when --python is set; non-macOS, Intel, and healthy arm64 venvs are unaffected. Co-authored-by: Daniel Han --- install.sh | 44 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/install.sh b/install.sh index f0af60e2d6..532203a51a 100755 --- a/install.sh +++ b/install.sh @@ -1530,17 +1530,51 @@ if [ -x "$VENV_DIR/bin/python" ]; then : > "$VENV_DIR/.unsloth-studio-owned" 2>/dev/null || true fi -# Guard against Python 3.13.8 torch import bug on Apple Silicon -# (skip when the user explicitly chose a version via --python) +# Guard against two independent Apple Silicon venv problems, in order: +# 1. uv may create the venv from a cached x86_64 (Rosetta) Python when a +# same-version x86_64 build is already cached (often because uv itself +# is an x86_64 build). That venv reports x86_64 to wheel resolvers, and +# PyTorch ships no macOS wheels on the CPU index for any architecture, +# so the torch install can never resolve. Recreate it with an +# arch-explicit arm64 CPython. +# 2. Python 3.13.8 has a known torch import bug. +# The two are independent: a venv may be x86_64 and, once recreated, still +# land on 3.13.8. So we re-inspect the interpreter between the checks instead +# of chaining them with elif, guaranteeing both invariants hold on whatever +# venv we end up with. Skip both when the user explicitly chose an interpreter +# via --python. if [ -z "$_USER_PYTHON" ] && [ "$OS" = "macos" ] && [ "$_ARCH" = "arm64" ]; then - _PY_VER=$("$VENV_DIR/bin/python" -c \ - "import sys; print('{}.{}.{}'.format(*sys.version_info[:3]))" 2>/dev/null || echo "") + _inspect_venv() { + "$VENV_DIR/bin/python" -c \ + "import platform, sys; print(platform.machine(), '{}.{}.{}'.format(*sys.version_info[:3]))" \ + 2>/dev/null || echo " " + } + _info=$(_inspect_venv) + _VENV_ARCH=${_info%% *} + _PY_VER=${_info##* } + + if [ "$_VENV_ARCH" = "x86_64" ]; then + echo " WARNING: venv was created with an x86_64 (Rosetta) Python on Apple Silicon." + echo " Recreating venv with native arm64 Python ${PYTHON_VERSION}..." + rm -rf "$VENV_DIR" + run_install_cmd "recreate venv (arm64)" uv venv "$VENV_DIR" \ + --python "cpython-${PYTHON_VERSION}-macos-aarch64-none" + if [ -x "$VENV_DIR/bin/python" ]; then + : > "$VENV_DIR/.unsloth-studio-owned" 2>/dev/null || true + fi + # Re-inspect: the recreated arm64 venv may still be 3.13.8. + _info=$(_inspect_venv) + _VENV_ARCH=${_info%% *} + _PY_VER=${_info##* } + fi + if [ "$_PY_VER" = "3.13.8" ]; then echo " WARNING: Python 3.13.8 has a known torch import bug." echo " Recreating venv with Python 3.12..." rm -rf "$VENV_DIR" PYTHON_VERSION="3.12" - run_install_cmd "recreate venv" uv venv "$VENV_DIR" --python "$PYTHON_VERSION" + run_install_cmd "recreate venv" uv venv "$VENV_DIR" \ + --python "cpython-${PYTHON_VERSION}-macos-aarch64-none" if [ -x "$VENV_DIR/bin/python" ]; then : > "$VENV_DIR/.unsloth-studio-owned" 2>/dev/null || true fi From e04ea3347e7767abef5f2896c71e04419008caab Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Sun, 31 May 2026 13:30:36 +0000 Subject: [PATCH 05/31] test(install): cover the Apple Silicon venv arch rebuild guard Extracts the real guard block from install.sh and asserts: clean arm64 venv untouched, x86_64 venv rebuilt as arm64, the x86_64-then-3.13.8 corner case, arm64 3.13.8 downgrade preserved, --python skip, and Intel/Rosetta no-op. Co-authored-by: Ramakrishna Bachu --- tests/sh/test_mac_intel_compat.sh | 74 +++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/tests/sh/test_mac_intel_compat.sh b/tests/sh/test_mac_intel_compat.sh index be9f756f90..673fd58cd6 100644 --- a/tests/sh/test_mac_intel_compat.sh +++ b/tests/sh/test_mac_intel_compat.sh @@ -563,6 +563,80 @@ else FAIL=$((FAIL + 1)) fi +echo "" +echo "=== Apple Silicon x86_64 (Rosetta) venv rebuild ===" + +# Extract the real guard block from install.sh so we exercise the shipped logic +# (comment header down to its column-0 closing fi). +_GUARD_FILE=$(mktemp) +awk '/Guard against two independent Apple Silicon venv problems/{f=1} f{print} f&&/^fi$/{exit}' \ + "$INSTALL_SH" > "$_GUARD_FILE" + +if [ ! -s "$_GUARD_FILE" ]; then + echo " FAIL: could not extract Apple Silicon venv guard from install.sh" + FAIL=$((FAIL + 1)) +else + # Runner: stub uv (via run_install_cmd) + a fake venv python, source the + # guard, then print " | ". + # The stub maps a uv arm64 selector to the interpreter uv would produce: + # cpython-3.12-* -> arm64 3.12.7, cpython-3.13-* -> arm64 $REBUILD_313_VERSION. + _RUNNER=$(mktemp) + cat > "$_RUNNER" << 'RUNNER_EOF' +GUARD="$1"; VENV_DIR="$2" +make_python() { # dir machine version + mkdir -p "$1/bin" + printf '#!/usr/bin/env bash\necho "%s %s"\n' "$2" "$3" > "$1/bin/python" + chmod +x "$1/bin/python" +} +RECREATE_LOG=$(mktemp); : > "$RECREATE_LOG" +run_install_cmd() { + shift # drop the human label + if [ "$1" = "uv" ] && [ "$2" = "venv" ]; then + dir="$3"; sel=""; shift 3 + while [ $# -gt 0 ]; do [ "$1" = "--python" ] && { sel="$2"; shift; }; shift; done + echo "$sel" >> "$RECREATE_LOG" + case "$sel" in + *3.12-macos-aarch64*) make_python "$dir" arm64 "3.12.7" ;; + *3.13-macos-aarch64*) make_python "$dir" arm64 "${REBUILD_313_VERSION:-3.13.3}" ;; + *) make_python "$dir" arm64 "$sel" ;; + esac + fi +} +[ "$INIT_ARCH" != none ] && make_python "$VENV_DIR" "$INIT_ARCH" "$INIT_VER" +PYTHON_VERSION="3.13" +. "$GUARD" >&2 # guard's user-facing echoes go to stderr; keep stdout clean +final="none"; [ -x "$VENV_DIR/bin/python" ] && final="$("$VENV_DIR/bin/python" -c x)" +printf '%s | %s\n' "$final" "$(paste -sd, "$RECREATE_LOG" 2>/dev/null)" +rm -f "$RECREATE_LOG" +RUNNER_EOF + + _run_guard() { # _USER_PYTHON OS _ARCH INIT_ARCH INIT_VER REBUILD_313_VERSION + _vd=$(mktemp -d) + env _USER_PYTHON="$1" OS="$2" _ARCH="$3" INIT_ARCH="$4" INIT_VER="$5" \ + REBUILD_313_VERSION="$6" bash "$_RUNNER" "$_GUARD_FILE" "$_vd/venv" + rm -rf "$_vd" + } + + assert_eq "clean arm64 venv left untouched" \ + "arm64 3.13.3 | " "$(_run_guard '' macos arm64 arm64 3.13.3 '')" + assert_eq "x86_64 venv rebuilt as arm64" \ + "arm64 3.13.3 | cpython-3.13-macos-aarch64-none" \ + "$(_run_guard '' macos arm64 x86_64 3.13.3 '')" + assert_eq "x86_64 venv that lands on 3.13.8 is rebuilt then downgraded to 3.12" \ + "arm64 3.12.7 | cpython-3.13-macos-aarch64-none,cpython-3.12-macos-aarch64-none" \ + "$(_run_guard '' macos arm64 x86_64 3.13.3 3.13.8)" + assert_eq "arm64 3.13.8 venv downgraded to 3.12" \ + "arm64 3.12.7 | cpython-3.12-macos-aarch64-none" \ + "$(_run_guard '' macos arm64 arm64 3.13.8 '')" + assert_eq "--python override skips the guard entirely" \ + "x86_64 3.13.3 | " "$(_run_guard 3.11 macos arm64 x86_64 3.13.3 '')" + assert_eq "x86_64 host (Intel/Rosetta shell) is a no-op here" \ + "x86_64 3.13.3 | " "$(_run_guard '' macos x86_64 x86_64 3.13.3 '')" + + rm -f "$_RUNNER" +fi +rm -f "$_GUARD_FILE" + echo "" echo "Results: $PASS passed, $FAIL failed" [ "$FAIL" -eq 0 ] || exit 1 From 5b1a8218e05f8270795958681ec5dc48f94a2669 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sun, 31 May 2026 14:23:14 +0000 Subject: [PATCH 06/31] Bump install.sh / install.ps1 pin to unsloth>=2026.5.9 --- install.ps1 | 10 +++++----- install.sh | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/install.ps1 b/install.ps1 index cab66f5ae1..d3942bf2fc 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1566,7 +1566,7 @@ shell.Run cmd, 0, False if ($SkipTorch) { # No-torch: install unsloth + unsloth-zoo with --no-deps, then # runtime deps (typer, safetensors, transformers, etc.) with --no-deps. - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.5.8" unsloth-zoo } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.5.9" unsloth-zoo } if ($baseInstallExit -eq 0) { # Resolve pydantic WITH deps so pip pins pydantic-core # to the matching version (no-torch-runtime.txt below @@ -1580,7 +1580,7 @@ shell.Run cmd, 0, False } } } else { - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.5.8" unsloth-zoo } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.5.9" unsloth-zoo } } if ($baseInstallExit -ne 0) { Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red @@ -1627,7 +1627,7 @@ shell.Run cmd, 0, False if ($SkipTorch) { # No-torch: install unsloth + unsloth-zoo with --no-deps, then # runtime deps (typer, safetensors, transformers, etc.) with --no-deps. - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.5.8" unsloth-zoo } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.5.9" unsloth-zoo } if ($baseInstallExit -eq 0) { # Same pydantic-with-deps trick as the migrated branch. $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython pydantic } @@ -1639,7 +1639,7 @@ shell.Run cmd, 0, False } } } elseif ($StudioLocalInstall) { - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.5.8" unsloth-zoo } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.5.9" unsloth-zoo } } else { $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth -- "$PackageName" } } @@ -1667,7 +1667,7 @@ shell.Run cmd, 0, False Write-TauriLog "STEP" "Installing unsloth" substep "installing unsloth (this may take a few minutes)..." if ($StudioLocalInstall) { - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.5.8" --torch-backend=auto } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.5.9" --torch-backend=auto } if ($baseInstallExit -ne 0) { Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red return (Exit-InstallFailure "Failed to install unsloth (exit code $baseInstallExit)" $baseInstallExit) diff --git a/install.sh b/install.sh index 532203a51a..89c506bc9d 100755 --- a/install.sh +++ b/install.sh @@ -2083,7 +2083,7 @@ if [ "$_MIGRATED" = true ]; then # to prevent transitive torch resolution. run_install_cmd "install unsloth (migrated no-torch)" uv pip install --python "$_VENV_PY" --no-deps \ --reinstall-package unsloth --reinstall-package unsloth-zoo \ - "unsloth>=2026.5.8" unsloth-zoo + "unsloth>=2026.5.9" unsloth-zoo # Resolve pydantic WITH deps so pip pins pydantic-core to the # matching version (no-torch-runtime.txt below is --no-deps). # All transitive deps are torch-free. @@ -2096,7 +2096,7 @@ if [ "$_MIGRATED" = true ]; then else run_install_cmd "install unsloth (migrated)" uv pip install --python "$_VENV_PY" \ --reinstall-package unsloth --reinstall-package unsloth-zoo \ - "unsloth>=2026.5.8" unsloth-zoo + "unsloth>=2026.5.9" unsloth-zoo fi if [ "$STUDIO_LOCAL_INSTALL" = true ]; then substep "overlaying local repo (editable)..." @@ -2300,7 +2300,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then # runtime deps (typer, safetensors, transformers, etc.) with --no-deps. run_install_cmd "install unsloth (no-torch)" uv pip install --python "$_VENV_PY" --no-deps \ --upgrade-package unsloth --upgrade-package unsloth-zoo \ - "unsloth>=2026.5.8" unsloth-zoo + "unsloth>=2026.5.9" unsloth-zoo # Same pydantic-with-deps trick as the migrated branch. run_install_cmd "install pydantic (with deps for compatible core)" \ uv pip install --python "$_VENV_PY" pydantic @@ -2318,7 +2318,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then fi elif [ "$STUDIO_LOCAL_INSTALL" = true ]; then run_install_cmd "install unsloth (local)" uv pip install --python "$_VENV_PY" \ - --upgrade-package unsloth "unsloth>=2026.5.8" unsloth-zoo + --upgrade-package unsloth "unsloth>=2026.5.9" unsloth-zoo substep "overlaying local repo (editable)..." run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps substep "overlaying unsloth-zoo from git main..." @@ -2350,7 +2350,7 @@ else tauri_log "STEP" "Installing Unsloth" substep "installing unsloth (this may take a few minutes)..." if [ "$STUDIO_LOCAL_INSTALL" = true ]; then - run_install_cmd "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.5.8" --torch-backend=auto + run_install_cmd "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.5.9" --torch-backend=auto substep "overlaying local repo (editable)..." run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps substep "overlaying unsloth-zoo from git main..." From 998feaf9d01fcd13b7dc1827bf5321183fa6363e Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sun, 31 May 2026 07:29:11 -0700 Subject: [PATCH 07/31] Update README.md --- README.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 948d84a789..ce1c2bb533 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ unsloth studio -p 8888 For cloud or global access, add `-H 0.0.0.0`. By default, Unsloth is accessible only locally. #### Update -To update, use the same install commands above or use `unsloth studio update`. +To update, use the same install commands above so `curl -fsSL https://unsloth.ai/install.sh | sh` or `irm https://unsloth.ai/install.ps1 | iex` #### Docker Use our [Docker image](https://hub.docker.com/r/unsloth/unsloth) ```unsloth/unsloth``` container. Run: @@ -171,7 +171,9 @@ unsloth studio -p 8888 ``` Then to update : ```bash -unsloth studio update +cd unsloth && git pull +./install.sh --local +unsloth studio -p 8888 ``` #### Developer installs: Windows PowerShell: @@ -184,7 +186,9 @@ unsloth studio -p 8888 ``` Then to update : ```bash -unsloth studio update +cd unsloth && git pull +./install.sh --local +unsloth studio -p 8888 ``` #### Nightly: MacOS, Linux, WSL: From 6bf101107a7b1fd599d2a829241d93d95488bf94 Mon Sep 17 00:00:00 2001 From: Michael Han <107991372+shimmyshimmer@users.noreply.github.com> Date: Sun, 31 May 2026 07:31:27 -0700 Subject: [PATCH 08/31] Removing unsloth studio update.md --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ce1c2bb533..562c35ff1a 100644 --- a/README.md +++ b/README.md @@ -72,10 +72,13 @@ Unsloth Studio (Beta) works on **Windows, Linux, WSL** and **macOS**. ```bash curl -fsSL https://unsloth.ai/install.sh | sh ``` +Use the same command to update. + #### Windows: ```powershell irm https://unsloth.ai/install.ps1 | iex ``` +Use the same command to update. #### Launch ```bash @@ -83,9 +86,6 @@ unsloth studio -p 8888 ``` For cloud or global access, add `-H 0.0.0.0`. By default, Unsloth is accessible only locally. -#### Update -To update, use the same install commands above so `curl -fsSL https://unsloth.ai/install.sh | sh` or `irm https://unsloth.ai/install.ps1 | iex` - #### Docker Use our [Docker image](https://hub.docker.com/r/unsloth/unsloth) ```unsloth/unsloth``` container. Run: ```bash From e3b52eb98a2ed577714fca382268f75cdd1189ad Mon Sep 17 00:00:00 2001 From: Michael Han <107991372+shimmyshimmer@users.noreply.github.com> Date: Sun, 31 May 2026 21:15:34 -0700 Subject: [PATCH 09/31] Use remove-circle icon for eject model button (#5906) Co-authored-by: Unsloth --- .../frontend/src/components/assistant-ui/model-selector.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/studio/frontend/src/components/assistant-ui/model-selector.tsx b/studio/frontend/src/components/assistant-ui/model-selector.tsx index 8058a4d322..0fcbefdabd 100644 --- a/studio/frontend/src/components/assistant-ui/model-selector.tsx +++ b/studio/frontend/src/components/assistant-ui/model-selector.tsx @@ -17,7 +17,7 @@ import { CloudIcon, DashboardSquare01Icon, FolderSearchIcon, - Logout01Icon, + RemoveCircleIcon, Search01Icon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; @@ -326,7 +326,7 @@ function ModelSelectorContent({ className="flex w-full items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-destructive transition-colors hover:bg-destructive/10" title="Eject model" > - + Eject loaded model
From dfba4cc5cae7c95871c0faced86b9cefd5808b28 Mon Sep 17 00:00:00 2001 From: Wasim Yousef Said Date: Mon, 1 Jun 2026 08:35:18 +0200 Subject: [PATCH 10/31] Studio: add HTML artifacts to chat (#5772) * Studio: add chat HTML artifact primitives * Studio: add local render_html tool support * Studio: wire render_html artifacts in chat UI * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: add chat artifact surface * Studio: mount chat artifact panel and overlay * Studio: fix chat artifact review regressions * Studio: fix chat artifact panel and sandbox previews * Studio: address chat artifact review follow-ups * Studio: polish chat artifact UI affordances * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: scope artifact IDs by message to prevent cross-turn collisions * Studio: fix artifact panel for local threads and surface tool errors * Studio: restrict artifact frame embedding to same-origin * Studio: stop local chat thread remount loop * Studio: fix chat artifact store cleanup regressions * Studio: shim artifact preview storage in sandbox * feat(chat): add artifact rendering controls * fix(chat): show artifact progress during tool calls * fix(chat): refine artifact preview behavior * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix(chat): ignore tool markers inside arguments * feat(chat): polish artifact preview panel * fix(chat): stabilize artifact panel behavior * fix(inference): merge duplicate Anthropic tool starts * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .../core/inference/anthropic_compat.py | 70 ++- studio/backend/core/inference/llama_cpp.py | 88 +++- .../core/inference/safetensors_agentic.py | 112 +++- .../core/inference/tool_call_parser.py | 26 +- studio/backend/core/inference/tools.py | 51 +- studio/backend/main.py | 6 +- studio/backend/models/inference.py | 11 +- studio/backend/routes/chat_history.py | 2 + studio/backend/routes/inference.py | 287 ++++++++--- .../backend/tests/test_anthropic_messages.py | 92 ++++ .../tests/test_safetensors_tool_loop.py | 126 ++++- .../components/assistant-ui/markdown-text.tsx | 181 +++---- .../src/components/assistant-ui/thread.tsx | 27 + .../components/assistant-ui/tool-group.tsx | 16 +- .../assistant-ui/tool-ui-render-html.tsx | 142 ++++++ .../frontend/src/components/ui/resizable.tsx | 102 ++-- .../src/features/chat/api/chat-adapter.ts | 117 +++-- .../features/chat/api/chat-settings-api.ts | 2 + .../features/chat/artifacts/artifact-card.tsx | 141 +++++ .../chat/artifacts/artifact-surface.tsx | 362 +++++++++++++ .../features/chat/artifacts/html-frame.tsx | 100 ++++ .../src/features/chat/artifacts/store.ts | 107 ++++ .../src/features/chat/artifacts/types.ts | 84 +++ .../frontend/src/features/chat/chat-page.tsx | 337 ++++++++++-- .../src/features/chat/chat-settings-sheet.tsx | 11 - .../chat/hooks/use-chat-model-runtime.ts | 12 +- .../chat/hooks/use-chat-sidebar-items.ts | 5 + studio/frontend/src/features/chat/index.ts | 5 + .../src/features/chat/shared-composer.tsx | 481 +++++++++++------- .../chat/stores/chat-runtime-store.ts | 56 ++ .../chat/utils/chat-settings-storage.ts | 23 + .../src/features/native-intents/index.ts | 11 + .../src/features/settings/tabs/chat-tab.tsx | 45 +- studio/frontend/src/i18n/locales/en.ts | 9 + studio/frontend/src/index.css | 143 ++++++ 35 files changed, 2800 insertions(+), 590 deletions(-) create mode 100644 studio/frontend/src/components/assistant-ui/tool-ui-render-html.tsx create mode 100644 studio/frontend/src/features/chat/artifacts/artifact-card.tsx create mode 100644 studio/frontend/src/features/chat/artifacts/artifact-surface.tsx create mode 100644 studio/frontend/src/features/chat/artifacts/html-frame.tsx create mode 100644 studio/frontend/src/features/chat/artifacts/store.ts create mode 100644 studio/frontend/src/features/chat/artifacts/types.ts create mode 100644 studio/frontend/src/features/native-intents/index.ts diff --git a/studio/backend/core/inference/anthropic_compat.py b/studio/backend/core/inference/anthropic_compat.py index bc792c3b99..cdb0fdebff 100644 --- a/studio/backend/core/inference/anthropic_compat.py +++ b/studio/backend/core/inference/anthropic_compat.py @@ -218,6 +218,8 @@ class AnthropicStreamEmitter: def __init__(self) -> None: self.block_index: int = 0 self._text_block_open: bool = False + self._open_tool_call_id: Optional[str] = None + self._open_tool_args_sent: bool = False self._prev_text: str = "" self._usage: dict = {} @@ -263,8 +265,10 @@ class AnthropicStreamEmitter: def finish(self, stop_reason: str = "end_turn") -> list[str]: """Close any open block and emit message_delta + message_stop.""" events = [] - if self._text_block_open: + if self._text_block_open or self._open_tool_call_id is not None: events.append(self._close_block()) + self._open_tool_call_id = None + self._open_tool_args_sent = False events.append( build_anthropic_sse_event( "message_delta", @@ -310,12 +314,26 @@ class AnthropicStreamEmitter: return events def _handle_tool_start(self, event: dict) -> list[str]: + tool_call_id = event.get("tool_call_id", "") + args = event.get("arguments", {}) + if tool_call_id and self._open_tool_call_id == tool_call_id: + return self._tool_arguments_delta(args) + events = [] - # Close current text block if open + # Close current text block if open. if self._text_block_open: events.append(self._close_block()) - # Open a tool_use block + # Defensive: if a replacement/different tool_start arrives while a + # tool_use block is open, close the stale block before starting another. + elif self._open_tool_call_id is not None: + events.append(self._close_block()) + self._open_tool_call_id = None + self._open_tool_args_sent = False + + # Open a tool_use block. self.block_index += 1 + self._open_tool_call_id = tool_call_id + self._open_tool_args_sent = False events.append( build_anthropic_sse_event( "content_block_start", @@ -324,35 +342,43 @@ class AnthropicStreamEmitter: "index": self.block_index, "content_block": { "type": "tool_use", - "id": event.get("tool_call_id", ""), + "id": tool_call_id, "name": event.get("tool_name", ""), "input": {}, }, }, ) ) - # Emit the arguments as input_json_delta - args = event.get("arguments", {}) - if args: - events.append( - build_anthropic_sse_event( - "content_block_delta", - { - "type": "content_block_delta", - "index": self.block_index, - "delta": { - "type": "input_json_delta", - "partial_json": json.dumps(args), - }, - }, - ) - ) + events.extend(self._tool_arguments_delta(args)) return events + def _tool_arguments_delta(self, args: dict) -> list[str]: + if not args: + return [] + if self._open_tool_args_sent: + return [] + self._open_tool_args_sent = True + return [ + build_anthropic_sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": self.block_index, + "delta": { + "type": "input_json_delta", + "partial_json": json.dumps(args), + }, + }, + ) + ] + def _handle_tool_end(self, event: dict) -> list[str]: events = [] - # Close the tool_use block - events.append(self._close_block()) + # Close the tool_use block. + if self._open_tool_call_id is not None or self._text_block_open: + events.append(self._close_block()) + self._open_tool_call_id = None + self._open_tool_args_sent = False # Emit custom tool_result event (non-standard, ignored by SDKs) events.append( build_anthropic_sse_event( diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index cce95fc34c..7bcf02dc35 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -52,6 +52,7 @@ from utils.subprocess_compat import ( windows_hidden_subprocess_kwargs as _windows_hidden_subprocess_kwargs, ) from core.inference.tool_call_parser import ( + RENDER_HTML_REPEAT_NUDGE, parse_tool_calls_from_text as _shared_parse_tool_calls_from_text, ) @@ -4616,6 +4617,7 @@ class LlamaCppBackend: # a transient failure are allowed (only block when the previous # identical call succeeded). _tool_call_history: list[tuple[str, bool]] = [] # (key, failed) + _render_html_succeeded = False # ── Re-prompt on plan-without-action ───────────────── # When the model describes what it intends to do (forward-looking @@ -4690,6 +4692,7 @@ class LlamaCppBackend: _iter_timings = None _stream_done = False _last_emitted = "" + provisional_render_html_tool_call_ids = set() stream_timeout = httpx.Timeout( connect = 10, @@ -4799,6 +4802,33 @@ class LlamaCppBackend: tool_calls_acc[idx]["function"][ "arguments" ] += func["arguments"] + current_name = tool_calls_acc[idx][ + "function" + ].get("name", "") + fallback_id = f"call_{idx}" + current_id = tool_calls_acc[idx].get( + "id", fallback_id + ) + already_started = ( + current_id + in provisional_render_html_tool_call_ids + ) + has_real_id = current_id != fallback_id + if ( + current_name == "render_html" + and not _render_html_succeeded + and not already_started + and has_real_id + ): + provisional_render_html_tool_call_ids.add( + current_id + ) + yield { + "type": "tool_start", + "tool_name": "render_html", + "tool_call_id": current_id, + "arguments": {}, + } continue # ── Reasoning tokens ── @@ -4980,13 +5010,25 @@ class LlamaCppBackend: "content": _stripped, } ) + available_tool_names = [ + tool.get("function", {}).get("name") + for tool in tools + if isinstance(tool, dict) + and isinstance(tool.get("function"), dict) + ] + available_tool_names = [ + name for name in available_tool_names if name + ] + tool_hint = ( + " or ".join(available_tool_names) or "an available tool" + ) conversation.append( { "role": "user", "content": ( "STOP. Do NOT write code or explain. " "You MUST call a tool NOW. " - "Call web_search or python immediately." + f"Call {tool_hint} immediately." ), } ) @@ -5158,7 +5200,12 @@ class LlamaCppBackend: arguments = json.loads(raw_args) except (json.JSONDecodeError, ValueError): if auto_heal_tool_calls: - arguments = {"query": raw_args} + heal_key = { + "python": "code", + "terminal": "command", + "render_html": "code", + }.get(tool_name, "query") + arguments = {heal_key: raw_args} else: arguments = {"raw": raw_args} else: @@ -5195,14 +5242,18 @@ class LlamaCppBackend: ) else: status_text = f"Calling: {tool_name}" - yield {"type": "status", "text": status_text} + _repeat_render_html = ( + tool_name == "render_html" and _render_html_succeeded + ) + if not _repeat_render_html: + yield {"type": "status", "text": status_text} - yield { - "type": "tool_start", - "tool_name": tool_name, - "tool_call_id": tc.get("id", ""), - "arguments": arguments, - } + yield { + "type": "tool_start", + "tool_name": tool_name, + "tool_call_id": tc.get("id", ""), + "arguments": arguments, + } # ── Duplicate call detection ────────────── # str(dict) is stable here: arguments always comes from @@ -5210,7 +5261,9 @@ class LlamaCppBackend: # so insertion order is deterministic (Python 3.7+). _tc_key = tool_name + str(arguments) _prev = _tool_call_history[-1] if _tool_call_history else None - if _prev and _prev[0] == _tc_key and not _prev[1]: + if _repeat_render_html: + result = RENDER_HTML_REPEAT_NUDGE + elif _prev and _prev[0] == _tc_key and not _prev[1]: result = ( "You already made this exact call. " "Do not repeat the same tool call. " @@ -5248,12 +5301,13 @@ class LlamaCppBackend: session_id = session_id, ) - yield { - "type": "tool_end", - "tool_name": tool_name, - "tool_call_id": tc.get("id", ""), - "result": result, - } + if not _repeat_render_html: + yield { + "type": "tool_end", + "tool_name": tool_name, + "tool_call_id": tc.get("id", ""), + "result": result, + } # Nudge model to try a different approach on errors _error_prefixes = ( @@ -5269,6 +5323,8 @@ class LlamaCppBackend: _is_error = isinstance(result, str) and result.lstrip().startswith( _error_prefixes ) + if tool_name == "render_html" and not _is_error: + _render_html_succeeded = True _tool_call_history.append((_tc_key, _is_error)) # Strip image sentinel before feeding result to the LLM # (the full result with sentinel is still yielded via diff --git a/studio/backend/core/inference/safetensors_agentic.py b/studio/backend/core/inference/safetensors_agentic.py index 73bb3d090a..94e9e303ab 100644 --- a/studio/backend/core/inference/safetensors_agentic.py +++ b/studio/backend/core/inference/safetensors_agentic.py @@ -18,6 +18,7 @@ cumulative text and dispatches them via ``core.inference.tools``. """ import json +import re import threading from typing import Callable, Generator, Optional from urllib.parse import urlparse @@ -27,6 +28,7 @@ from loggers import get_logger from core.inference.tool_call_parser import ( BUDGET_EXHAUSTED_NUDGE, DUPLICATE_CALL_NUDGE, + RENDER_HTML_REPEAT_NUDGE, TOOL_ERROR_NUDGE, TOOL_ERROR_PREFIXES, TOOL_XML_SIGNALS, @@ -66,7 +68,34 @@ def _status_for_tool(tool_name: str, arguments: dict) -> str: return f"Calling: {tool_name}" -_CANONICAL_HEAL_ARG = {"python": "code", "terminal": "command"} +_CANONICAL_HEAL_ARG = { + "python": "code", + "terminal": "command", + "render_html": "code", +} + + +_FUNCTION_SIGNAL_RE = re.compile(r"") +_TOOL_CALL_NAME_RE = re.compile(r'"name"\s*:\s*"([\w-]+)"') + + +def _detect_render_html_tool_start(content: str) -> bool: + """Return True when the first drained tool call is clearly render_html.""" + function_match = _FUNCTION_SIGNAL_RE.search(content) + tool_call_index = content.find("") + if not function_match and tool_call_index < 0: + return False + + if function_match and ( + tool_call_index < 0 or function_match.start() < tool_call_index + ): + return function_match.group(1) == "render_html" + + if tool_call_index >= 0: + name_match = _TOOL_CALL_NAME_RE.search(content[tool_call_index:]) + return bool(name_match and name_match.group(1) == "render_html") + + return False def _coerce_arguments(raw_args, *, heal: bool, tool_name: str = "") -> dict: @@ -135,6 +164,7 @@ def run_safetensors_tool_loop( """ conversation = list(messages) tool_call_history: list[tuple[str, bool]] = [] + render_html_succeeded = False final_attempt_done = False allowed_tool_names = { (tool.get("function") or {}).get("name") @@ -161,6 +191,8 @@ def run_safetensors_tool_loop( content_accum = "" cumulative_display = "" last_emitted = "" + provisional_render_html_started = False + provisional_render_html_id = f"call_{next_call_id}" gen = single_turn(conversation) prev_cumulative = "" @@ -179,6 +211,18 @@ def run_safetensors_tool_loop( content_accum += delta if detect_state == _state_draining: + if ( + not render_html_succeeded + and not provisional_render_html_started + and _detect_render_html_tool_start(content_accum) + ): + provisional_render_html_started = True + yield { + "type": "tool_start", + "tool_name": "render_html", + "tool_call_id": provisional_render_html_id, + "arguments": {}, + } continue if detect_state == _state_streaming: @@ -196,6 +240,18 @@ def run_safetensors_tool_loop( yield {"type": "content", "text": cleaned_before} cumulative_display = candidate detect_state = _state_draining + if ( + not render_html_succeeded + and not provisional_render_html_started + and _detect_render_html_tool_start(content_accum) + ): + provisional_render_html_started = True + yield { + "type": "tool_start", + "tool_name": "render_html", + "tool_call_id": provisional_render_html_id, + "arguments": {}, + } continue cumulative_display = candidate cleaned = strip_tool_markup(cumulative_display) @@ -222,6 +278,18 @@ def run_safetensors_tool_loop( if is_match: detect_state = _state_draining + if ( + not render_html_succeeded + and not provisional_render_html_started + and _detect_render_html_tool_start(content_accum) + ): + provisional_render_html_started = True + yield { + "type": "tool_start", + "tool_name": "render_html", + "tool_call_id": provisional_render_html_id, + "arguments": {}, + } elif is_prefix and len(stripped) < _MAX_BUFFER_CHARS: continue else: @@ -282,6 +350,13 @@ def run_safetensors_tool_loop( # literal "" prose is preserved. if content_accum: yield {"type": "content", "text": content_accum} + if provisional_render_html_started: + yield { + "type": "tool_end", + "tool_name": "render_html", + "tool_call_id": provisional_render_html_id, + "result": "Error: render_html tool call could not be parsed.", + } yield {"type": "status", "text": ""} return content_text = strip_tool_markup(content_accum, final = True) @@ -308,16 +383,20 @@ def run_safetensors_tool_loop( tool_name = tool_name, ) - yield {"type": "status", "text": _status_for_tool(tool_name, arguments)} - yield { - "type": "tool_start", - "tool_name": tool_name, - "tool_call_id": tc.get("id", ""), - "arguments": arguments, - } + repeat_render_html = tool_name == "render_html" and render_html_succeeded + if not repeat_render_html: + yield {"type": "status", "text": _status_for_tool(tool_name, arguments)} + yield { + "type": "tool_start", + "tool_name": tool_name, + "tool_call_id": tc.get("id", ""), + "arguments": arguments, + } tc_key = tool_name + str(arguments) - if allowed_tool_names and tool_name not in allowed_tool_names: + if repeat_render_html: + result = RENDER_HTML_REPEAT_NUDGE + elif allowed_tool_names and tool_name not in allowed_tool_names: result = ( f"Error: tool '{tool_name}' is not enabled for this " "request. Use one of the enabled tools or provide a " @@ -345,16 +424,19 @@ def run_safetensors_tool_loop( logger.exception("Tool %s raised: %s", tool_name, exc) result = f"Error: tool raised an exception: {exc}" - yield { - "type": "tool_end", - "tool_name": tool_name, - "tool_call_id": tc.get("id", ""), - "result": result, - } + if not repeat_render_html: + yield { + "type": "tool_end", + "tool_name": tool_name, + "tool_call_id": tc.get("id", ""), + "result": result, + } is_error = isinstance(result, str) and result.lstrip().startswith( TOOL_ERROR_PREFIXES ) + if tool_name == "render_html" and not is_error: + render_html_succeeded = True tool_call_history.append((tc_key, is_error)) # Strip frontend image sentinel from the model's view. diff --git a/studio/backend/core/inference/tool_call_parser.py b/studio/backend/core/inference/tool_call_parser.py index 2f94990623..dacbc19ac0 100644 --- a/studio/backend/core/inference/tool_call_parser.py +++ b/studio/backend/core/inference/tool_call_parser.py @@ -49,6 +49,12 @@ DUPLICATE_CALL_NUDGE = ( "provide your final answer now." ) +RENDER_HTML_REPEAT_NUDGE = ( + "Error: render_html was already called for this response. Do not call " + "render_html again in this response unless the user asks for changes. " + "Provide the final answer now." +) + TOOL_ERROR_NUDGE = ( "\n\nThe tool call encountered an issue. Please try a different " "approach or rephrase your request." @@ -70,6 +76,20 @@ _TC_FUNC_CLOSE_RE = re.compile(r"\s*\s*$") # `issue-number`, `repo-name`); using `\w+` here dropped those keys. _TC_PARAM_START_RE = re.compile(r"\s*") _TC_PARAM_CLOSE_RE = re.compile(r"\s*\s*$") +_PARAM_CLOSE_TAG = "" +_FUNC_CLOSE_TAG = "" + + +def _inside_open_parameter(content: str, pos: int) -> bool: + """Return True when ``pos`` falls inside an unclosed parameter value.""" + last_param_start = -1 + for match in _TC_PARAM_START_RE.finditer(content, 0, pos): + last_param_start = match.start() + if last_param_start < 0: + return False + last_param_close = content.rfind(_PARAM_CLOSE_TAG, 0, pos) + last_func_close = content.rfind(_FUNC_CLOSE_TAG, 0, pos) + return last_param_start > max(last_param_close, last_func_close) def strip_tool_markup(text: str, *, final: bool = False) -> str: @@ -151,7 +171,11 @@ def parse_tool_calls_from_text(content: str, *, id_offset: int = 0) -> list[dict # optional; don't use as body boundary because code # values can contain that literal. if not tool_calls: - func_starts = list(_TC_FUNC_START_RE.finditer(content)) + func_starts = [ + fm + for fm in _TC_FUNC_START_RE.finditer(content) + if not _inside_open_parameter(content, fm.start()) + ] for idx, fm in enumerate(func_starts): func_name = fm.group(1) body_start = fm.end() diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index baf1236456..eecb84ca27 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -514,7 +514,35 @@ TERMINAL_TOOL = { }, } -ALL_TOOLS = [WEB_SEARCH_TOOL, PYTHON_TOOL, TERMINAL_TOOL] +RENDER_HTML_TOOL = { + "type": "function", + "function": { + "name": "render_html", + "description": ( + "Render a self-contained HTML/CSS/JavaScript artifact for the user. " + "Call this at most once per assistant response unless the user " + "explicitly asks for changes in that response. Future user requests " + "for new artifacts may call render_html once. Put the entire document " + "in code, including any CSS in