diff --git a/.github/workflows/studio-inference-smoke.yml b/.github/workflows/studio-inference-smoke.yml
index 6def56f769..c1297b84cc 100644
--- a/.github/workflows/studio-inference-smoke.yml
+++ b/.github/workflows/studio-inference-smoke.yml
@@ -296,6 +296,8 @@ jobs:
- name: Upload logs
# Always upload so green runs are still reviewable.
if: always()
+ # Diagnostic only: a transient artifact-service drop must not fail a green job.
+ continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: openai-anthropic-log
@@ -771,6 +773,8 @@ jobs:
- name: Upload logs
# Always upload so green runs are still reviewable.
if: always()
+ # Diagnostic only: a transient artifact-service drop must not fail a green job.
+ continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: tool-calling-log
@@ -1043,6 +1047,8 @@ jobs:
- name: Upload logs
# Always upload so green runs are still reviewable.
if: always()
+ # Diagnostic only: a transient artifact-service drop must not fail a green job.
+ continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: json-images-log
diff --git a/.github/workflows/studio-mac-inference-smoke.yml b/.github/workflows/studio-mac-inference-smoke.yml
index 1096b1abb4..e08ac6ca68 100644
--- a/.github/workflows/studio-mac-inference-smoke.yml
+++ b/.github/workflows/studio-mac-inference-smoke.yml
@@ -289,6 +289,8 @@ jobs:
- name: Upload logs
# Always upload so green runs are still reviewable.
if: always()
+ # Diagnostic only: a transient artifact-service drop must not fail a green job.
+ continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: openai-anthropic-log
@@ -649,6 +651,8 @@ jobs:
- name: Upload logs
# Always upload so green runs are still reviewable.
if: always()
+ # Diagnostic only: a transient artifact-service drop must not fail a green job.
+ continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: tool-calling-log
@@ -1025,6 +1029,8 @@ jobs:
- name: Upload logs
# Always upload so green runs are still reviewable.
if: always()
+ # Diagnostic only: a transient artifact-service drop must not fail a green job.
+ continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: json-images-log
diff --git a/.github/workflows/studio-windows-inference-smoke.yml b/.github/workflows/studio-windows-inference-smoke.yml
index 6253b9d213..bbe6b9e33a 100644
--- a/.github/workflows/studio-windows-inference-smoke.yml
+++ b/.github/workflows/studio-windows-inference-smoke.yml
@@ -65,6 +65,18 @@ jobs:
with:
persist-credentials: false
+ # Fast GPU-free gate: parse setup.ps1 and run the Resolve-CudaToolkit unit
+ # test (deferred Windows CUDA Toolkit check) before the heavy GGUF smoke.
+ - name: setup.ps1 unit test (Resolve-CudaToolkit)
+ shell: pwsh
+ run: |
+ $errs = $null
+ [void][System.Management.Automation.Language.Parser]::ParseFile(
+ (Resolve-Path studio/setup.ps1).Path, [ref]$null, [ref]$errs)
+ if ($errs) { $errs | ForEach-Object { $_.ToString() }; exit 1 }
+ Write-Host "setup.ps1 parsed with no errors"
+ pwsh -NoProfile -File tests/studio/test_resolve_cuda_toolkit.ps1
+
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '22'
@@ -360,6 +372,9 @@ jobs:
- name: Collect llama-server logs
if: always()
+ # A transient Windows DLL-init crash (0xC0000142) in this diagnostic
+ # copy must not fail an otherwise-green job.
+ continue-on-error: true
shell: bash
# Copy llama-server's own stdout/stderr (teed by Studio under
# ~/.unsloth/studio/logs/llama-server/) into the workspace so
@@ -373,6 +388,8 @@ jobs:
- name: Upload logs
if: always()
+ # Diagnostic only: a transient artifact-service drop must not fail a green job.
+ continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: windows-openai-anthropic-log
@@ -788,6 +805,9 @@ jobs:
- name: Collect llama-server logs
if: always()
+ # A transient Windows DLL-init crash (0xC0000142) in this diagnostic
+ # copy must not fail an otherwise-green job.
+ continue-on-error: true
shell: bash
# Copy llama-server's own stdout/stderr (teed by Studio under
# ~/.unsloth/studio/logs/llama-server/) into the workspace so
@@ -801,6 +821,8 @@ jobs:
- name: Upload logs
if: always()
+ # Diagnostic only: a transient artifact-service drop must not fail a green job.
+ continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: windows-tool-calling-log
@@ -1186,6 +1208,9 @@ jobs:
- name: Collect llama-server logs
if: always()
+ # A transient Windows DLL-init crash (0xC0000142) in this diagnostic
+ # copy must not fail an otherwise-green job.
+ continue-on-error: true
shell: bash
# Copy llama-server's own stdout/stderr (teed by Studio under
# ~/.unsloth/studio/logs/llama-server/) into the workspace so
@@ -1199,6 +1224,8 @@ jobs:
- name: Upload logs
if: always()
+ # Diagnostic only: a transient artifact-service drop must not fail a green job.
+ continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: windows-json-images-log
diff --git a/README.md b/README.md
index 948d84a789..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 or use `unsloth studio update`.
-
#### Docker
Use our [Docker image](https://hub.docker.com/r/unsloth/unsloth) ```unsloth/unsloth``` container. Run:
```bash
@@ -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:
diff --git a/install.ps1 b/install.ps1
index cab66f5ae1..47c72bcdc1 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.10" 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.10" 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.10" 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.10" 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.10" --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 f0af60e2d6..15755c589f 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
@@ -2049,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.10" 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.
@@ -2062,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.10" unsloth-zoo
fi
if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
substep "overlaying local repo (editable)..."
@@ -2266,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.10" 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
@@ -2284,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.10" 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..."
@@ -2316,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.10" --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..."
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/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..0f5dbfa237 100644
--- a/studio/backend/core/inference/tools.py
+++ b/studio/backend/core/inference/tools.py
@@ -421,6 +421,35 @@ _workdirs: dict[str, str] = {}
# Non-matching session_ids collapse to ``_invalid`` to block cross-session escapes.
_SESSION_ID_RE = re.compile(r"\A[A-Za-z0-9_\-]{1,64}\Z")
+_PROJECT_SESSION_PREFIX = "project-"
+
+
+def _get_project_workdir(session_id: str) -> str | None:
+ if not session_id.startswith(_PROJECT_SESSION_PREFIX):
+ return None
+ project_id = session_id[len(_PROJECT_SESSION_PREFIX) :]
+ if not project_id or not _SESSION_ID_RE.match(project_id):
+ return None
+ try:
+ from storage.studio_db import ensure_chat_project_workspace
+
+ project = ensure_chat_project_workspace(project_id)
+ except Exception:
+ logger.warning(
+ "Failed to resolve project sandbox for %s", session_id, exc_info = True
+ )
+ return None
+ if not project:
+ return None
+ root_path = project.get("rootPath")
+ sandbox_path = project.get("sandboxPath")
+ if not root_path or not sandbox_path:
+ return None
+ root_real = os.path.realpath(root_path)
+ sandbox_real = os.path.realpath(sandbox_path)
+ if sandbox_real != root_real and not sandbox_real.startswith(root_real + os.sep):
+ return None
+ return sandbox_real
def _get_workdir(session_id: str | None = None) -> str:
@@ -430,7 +459,14 @@ def _get_workdir(session_id: str | None = None) -> str:
if key not in _workdirs or not os.path.isdir(_workdirs[key]):
home = os.path.expanduser("~")
sandbox_root = os.path.join(home, "studio_sandbox")
- if session_id and _SESSION_ID_RE.match(session_id):
+ project_workdir = (
+ _get_project_workdir(session_id)
+ if session_id and _SESSION_ID_RE.match(session_id)
+ else None
+ )
+ if project_workdir:
+ workdir = project_workdir
+ elif session_id and _SESSION_ID_RE.match(session_id):
workdir = os.path.join(sandbox_root, session_id)
if not os.path.realpath(workdir).startswith(
os.path.realpath(sandbox_root) + os.sep
@@ -453,6 +489,10 @@ def _get_workdir(session_id: str | None = None) -> str:
return _workdirs[key]
+def get_sandbox_workdir(session_id: str | None = None) -> str:
+ return _get_workdir(session_id)
+
+
WEB_SEARCH_TOOL = {
"type": "function",
"function": {
@@ -514,7 +554,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